From a3d2acff77abc0da324171a6e1257efc8368b2e3 Mon Sep 17 00:00:00 2001 From: Benjamin Bennett Date: Wed, 20 Mar 2024 05:53:50 +0000 Subject: [PATCH 01/12] Enforce provider-defined function parameter naming --- function/definition.go | 22 ++++-- function/definition_test.go | 83 ++++++++++----------- function/parameter.go | 11 --- internal/toproto5/function.go | 15 +--- internal/toproto5/function_test.go | 7 +- internal/toproto5/getfunctions_test.go | 9 +-- internal/toproto5/getproviderschema_test.go | 9 +-- internal/toproto6/function.go | 15 +--- internal/toproto6/function_test.go | 7 +- internal/toproto6/getfunctions_test.go | 9 +-- internal/toproto6/getproviderschema_test.go | 9 +-- 11 files changed, 72 insertions(+), 124 deletions(-) diff --git a/function/definition.go b/function/definition.go index 39c1a408e..99a07dfc8 100644 --- a/function/definition.go +++ b/function/definition.go @@ -111,13 +111,18 @@ func (d Definition) ValidateImplementation(ctx context.Context) diag.Diagnostics paramNames := make(map[string]int, len(d.Parameters)) for pos, param := range d.Parameters { name := param.GetName() - // If name is not set, default the param name based on position: "param1", "param2", etc. + // If name is not set, add an error diagnostic, parameter names are mandatory. if name == "" { - name = fmt.Sprintf("%s%d", DefaultParameterNamePrefix, pos+1) + diags.AddError( + "Invalid Function Definition", + "When validating the function definition, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("Parameter at position %d does not have a name", pos), + ) } conflictPos, exists := paramNames[name] - if exists { + if exists && name != "" { diags.AddError( "Invalid Function Definition", "When validating the function definition, an implementation issue was found. "+ @@ -133,13 +138,18 @@ func (d Definition) ValidateImplementation(ctx context.Context) diag.Diagnostics if d.VariadicParameter != nil { name := d.VariadicParameter.GetName() - // If name is not set, default the variadic param name + // If name is not set, add an error diagnostic, parameter names are mandatory. if name == "" { - name = DefaultVariadicParameterName + diags.AddError( + "Invalid Function Definition", + "When validating the function definition, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "The variadic parameter does not have a name", + ) } conflictPos, exists := paramNames[name] - if exists { + if exists && name != "" { diags.AddError( "Invalid Function Definition", "When validating the function definition, an implementation issue was found. "+ diff --git a/function/definition_test.go b/function/definition_test.go index 8c1a9387d..6f6635ee5 100644 --- a/function/definition_test.go +++ b/function/definition_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/function" ) @@ -157,13 +158,21 @@ func TestDefinitionValidateImplementation(t *testing.T) { Return: function.StringReturn{}, }, }, - "valid-only-variadic": { + "missing-variadic-param-name": { definition: function.Definition{ VariadicParameter: function.StringParameter{}, Return: function.StringReturn{}, }, + expected: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Function Definition", + "When validating the function definition, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "The variadic parameter does not have a name", + ), + }, }, - "valid-param-name-defaults": { + "missing-param-names": { definition: function.Definition{ Parameters: []function.Parameter{ function.StringParameter{}, @@ -171,8 +180,22 @@ func TestDefinitionValidateImplementation(t *testing.T) { }, Return: function.StringReturn{}, }, + expected: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Function Definition", + "When validating the function definition, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "Parameter at position 0 does not have a name", + ), + diag.NewErrorDiagnostic( + "Invalid Function Definition", + "When validating the function definition, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "Parameter at position 1 does not have a name", + ), + }, }, - "valid-param-names-defaults-with-variadic": { + "missing-param-names-with-variadic": { definition: function.Definition{ Parameters: []function.Parameter{ function.StringParameter{}, @@ -180,6 +203,20 @@ func TestDefinitionValidateImplementation(t *testing.T) { VariadicParameter: function.NumberParameter{}, Return: function.StringReturn{}, }, + expected: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Function Definition", + "When validating the function definition, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "Parameter at position 0 does not have a name", + ), + diag.NewErrorDiagnostic( + "Invalid Function Definition", + "When validating the function definition, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "The variadic parameter does not have a name", + ), + }, }, "result-missing": { definition: function.Definition{}, @@ -223,26 +260,6 @@ func TestDefinitionValidateImplementation(t *testing.T) { ), }, }, - "conflicting-param-name-with-default": { - definition: function.Definition{ - Parameters: []function.Parameter{ - function.StringParameter{ - Name: "param2", - }, - function.Float64Parameter{}, // defaults to param2 - }, - Return: function.StringReturn{}, - }, - expected: diag.Diagnostics{ - diag.NewErrorDiagnostic( - "Invalid Function Definition", - "When validating the function definition, an implementation issue was found. "+ - "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ - "Parameter names must be unique. "+ - "Parameters at position 0 and 1 have the same name \"param2\"", - ), - }, - }, "conflicting-param-names-variadic": { definition: function.Definition{ Parameters: []function.Parameter{ @@ -319,26 +336,6 @@ func TestDefinitionValidateImplementation(t *testing.T) { ), }, }, - "conflicting-param-name-with-variadic-default": { - definition: function.Definition{ - Parameters: []function.Parameter{ - function.Float64Parameter{ - Name: function.DefaultVariadicParameterName, - }, - }, - VariadicParameter: function.BoolParameter{}, // defaults to varparam - Return: function.StringReturn{}, - }, - expected: diag.Diagnostics{ - diag.NewErrorDiagnostic( - "Invalid Function Definition", - "When validating the function definition, an implementation issue was found. "+ - "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ - "Parameter names must be unique. "+ - "Parameter at position 0 and the variadic parameter have the same name \"varparam\"", - ), - }, - }, } for name, testCase := range testCases { diff --git a/function/parameter.go b/function/parameter.go index 14a9fbb5d..e5add8828 100644 --- a/function/parameter.go +++ b/function/parameter.go @@ -7,17 +7,6 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" ) -const ( - // DefaultParameterNamePrefix is the prefix used to default the name of parameters which do not declare - // a name. Use this to prevent Terraform errors for missing names. This prefix is used with the parameter - // position in a function definition to create a unique name (param1, param2, etc.) - DefaultParameterNamePrefix = "param" - - // DefaultVariadicParameterName is the default name given to a variadic parameter that does not declare - // a name. Use this to prevent Terraform errors for missing names. - DefaultVariadicParameterName = "varparam" -) - // Parameter is the interface for defining function parameters. type Parameter interface { // GetAllowNullValue should return if the parameter accepts a null value. diff --git a/internal/toproto5/function.go b/internal/toproto5/function.go index 7b4cfc071..5ee2e16e2 100644 --- a/internal/toproto5/function.go +++ b/internal/toproto5/function.go @@ -5,7 +5,6 @@ package toproto5 import ( "context" - "fmt" "github.com/hashicorp/terraform-plugin-go/tfprotov5" @@ -30,25 +29,13 @@ func Function(ctx context.Context, fw function.Definition) *tfprotov5.Function { proto.DescriptionKind = tfprotov5.StringKindPlain } - for i, fwParameter := range fw.Parameters { + for _, fwParameter := range fw.Parameters { protoParam := FunctionParameter(ctx, fwParameter) - - // If name is not set, default the param name based on position: "param1", "param2", etc. - if protoParam.Name == "" { - protoParam.Name = fmt.Sprintf("%s%d", function.DefaultParameterNamePrefix, i+1) - } - proto.Parameters = append(proto.Parameters, protoParam) } if fw.VariadicParameter != nil { protoParam := FunctionParameter(ctx, fw.VariadicParameter) - - // If name is not set, default the variadic param name - if protoParam.Name == "" { - protoParam.Name = function.DefaultVariadicParameterName - } - proto.VariadicParameter = protoParam } diff --git a/internal/toproto5/function_test.go b/internal/toproto5/function_test.go index 65dfbd0ae..add947b0b 100644 --- a/internal/toproto5/function_test.go +++ b/internal/toproto5/function_test.go @@ -143,7 +143,7 @@ func TestFunction(t *testing.T) { }, }, }, - "parameters-defaults": { + "parameters-unnamed": { fw: function.Definition{ Parameters: []function.Parameter{ function.BoolParameter{}, @@ -156,20 +156,16 @@ func TestFunction(t *testing.T) { expected: &tfprotov5.Function{ Parameters: []*tfprotov5.FunctionParameter{ { - Name: "param1", Type: tftypes.Bool, }, { - Name: "param2", Type: tftypes.Number, }, { - Name: "param3", Type: tftypes.String, }, }, VariadicParameter: &tfprotov5.FunctionParameter{ - Name: function.DefaultVariadicParameterName, Type: tftypes.Number, }, Return: &tfprotov5.FunctionReturn{ @@ -212,7 +208,6 @@ func TestFunction(t *testing.T) { Type: tftypes.String, }, VariadicParameter: &tfprotov5.FunctionParameter{ - Name: function.DefaultVariadicParameterName, Type: tftypes.String, }, }, diff --git a/internal/toproto5/getfunctions_test.go b/internal/toproto5/getfunctions_test.go index 58bec8735..f90ed5a7b 100644 --- a/internal/toproto5/getfunctions_test.go +++ b/internal/toproto5/getfunctions_test.go @@ -8,12 +8,13 @@ import ( "testing" "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-go/tfprotov5" + "github.com/hashicorp/terraform-plugin-go/tftypes" + "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/function" "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" "github.com/hashicorp/terraform-plugin-framework/internal/toproto5" - "github.com/hashicorp/terraform-plugin-go/tfprotov5" - "github.com/hashicorp/terraform-plugin-go/tftypes" ) func TestGetFunctionsResponse(t *testing.T) { @@ -138,15 +139,12 @@ func TestGetFunctionsResponse(t *testing.T) { "testfunction": { Parameters: []*tfprotov5.FunctionParameter{ { - Name: "param1", Type: tftypes.Bool, }, { - Name: "param2", Type: tftypes.Number, }, { - Name: "param3", Type: tftypes.String, }, }, @@ -214,7 +212,6 @@ func TestGetFunctionsResponse(t *testing.T) { Type: tftypes.String, }, VariadicParameter: &tfprotov5.FunctionParameter{ - Name: function.DefaultVariadicParameterName, Type: tftypes.String, }, }, diff --git a/internal/toproto5/getproviderschema_test.go b/internal/toproto5/getproviderschema_test.go index a54fd1138..ee54a5075 100644 --- a/internal/toproto5/getproviderschema_test.go +++ b/internal/toproto5/getproviderschema_test.go @@ -8,6 +8,9 @@ import ( "testing" "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-go/tfprotov5" + "github.com/hashicorp/terraform-plugin-go/tftypes" + "github.com/hashicorp/terraform-plugin-framework/attr" datasourceschema "github.com/hashicorp/terraform-plugin-framework/datasource/schema" "github.com/hashicorp/terraform-plugin-framework/function" @@ -18,8 +21,6 @@ import ( providerschema "github.com/hashicorp/terraform-plugin-framework/provider/schema" resourceschema "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/types" - "github.com/hashicorp/terraform-plugin-go/tfprotov5" - "github.com/hashicorp/terraform-plugin-go/tftypes" ) // TODO: DynamicPseudoType support @@ -1072,15 +1073,12 @@ func TestGetProviderSchemaResponse(t *testing.T) { "testfunction": { Parameters: []*tfprotov5.FunctionParameter{ { - Name: "param1", Type: tftypes.Bool, }, { - Name: "param2", Type: tftypes.Number, }, { - Name: "param3", Type: tftypes.String, }, }, @@ -1154,7 +1152,6 @@ func TestGetProviderSchemaResponse(t *testing.T) { Type: tftypes.String, }, VariadicParameter: &tfprotov5.FunctionParameter{ - Name: function.DefaultVariadicParameterName, Type: tftypes.String, }, }, diff --git a/internal/toproto6/function.go b/internal/toproto6/function.go index 90925be10..70edabf2e 100644 --- a/internal/toproto6/function.go +++ b/internal/toproto6/function.go @@ -5,7 +5,6 @@ package toproto6 import ( "context" - "fmt" "github.com/hashicorp/terraform-plugin-go/tfprotov6" @@ -30,25 +29,13 @@ func Function(ctx context.Context, fw function.Definition) *tfprotov6.Function { proto.DescriptionKind = tfprotov6.StringKindPlain } - for i, fwParameter := range fw.Parameters { + for _, fwParameter := range fw.Parameters { protoParam := FunctionParameter(ctx, fwParameter) - - // If name is not set, default the param name based on position: "param1", "param2", etc. - if protoParam.Name == "" { - protoParam.Name = fmt.Sprintf("%s%d", function.DefaultParameterNamePrefix, i+1) - } - proto.Parameters = append(proto.Parameters, protoParam) } if fw.VariadicParameter != nil { protoParam := FunctionParameter(ctx, fw.VariadicParameter) - - // If name is not set, default the variadic param name - if protoParam.Name == "" { - protoParam.Name = function.DefaultVariadicParameterName - } - proto.VariadicParameter = protoParam } diff --git a/internal/toproto6/function_test.go b/internal/toproto6/function_test.go index 00f914c58..e19f7c3b3 100644 --- a/internal/toproto6/function_test.go +++ b/internal/toproto6/function_test.go @@ -143,7 +143,7 @@ func TestFunction(t *testing.T) { }, }, }, - "parameters-defaults": { + "parameters-unnamed": { fw: function.Definition{ Parameters: []function.Parameter{ function.BoolParameter{}, @@ -156,20 +156,16 @@ func TestFunction(t *testing.T) { expected: &tfprotov6.Function{ Parameters: []*tfprotov6.FunctionParameter{ { - Name: "param1", Type: tftypes.Bool, }, { - Name: "param2", Type: tftypes.Number, }, { - Name: "param3", Type: tftypes.String, }, }, VariadicParameter: &tfprotov6.FunctionParameter{ - Name: function.DefaultVariadicParameterName, Type: tftypes.Number, }, Return: &tfprotov6.FunctionReturn{ @@ -212,7 +208,6 @@ func TestFunction(t *testing.T) { Type: tftypes.String, }, VariadicParameter: &tfprotov6.FunctionParameter{ - Name: function.DefaultVariadicParameterName, Type: tftypes.String, }, }, diff --git a/internal/toproto6/getfunctions_test.go b/internal/toproto6/getfunctions_test.go index 3b4dfa9f1..8de74f894 100644 --- a/internal/toproto6/getfunctions_test.go +++ b/internal/toproto6/getfunctions_test.go @@ -8,12 +8,13 @@ import ( "testing" "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-go/tftypes" + "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/function" "github.com/hashicorp/terraform-plugin-framework/internal/fwserver" "github.com/hashicorp/terraform-plugin-framework/internal/toproto6" - "github.com/hashicorp/terraform-plugin-go/tfprotov6" - "github.com/hashicorp/terraform-plugin-go/tftypes" ) func TestGetFunctionsResponse(t *testing.T) { @@ -138,15 +139,12 @@ func TestGetFunctionsResponse(t *testing.T) { "testfunction": { Parameters: []*tfprotov6.FunctionParameter{ { - Name: "param1", Type: tftypes.Bool, }, { - Name: "param2", Type: tftypes.Number, }, { - Name: "param3", Type: tftypes.String, }, }, @@ -214,7 +212,6 @@ func TestGetFunctionsResponse(t *testing.T) { Type: tftypes.String, }, VariadicParameter: &tfprotov6.FunctionParameter{ - Name: function.DefaultVariadicParameterName, Type: tftypes.String, }, }, diff --git a/internal/toproto6/getproviderschema_test.go b/internal/toproto6/getproviderschema_test.go index b53854275..f49223c2d 100644 --- a/internal/toproto6/getproviderschema_test.go +++ b/internal/toproto6/getproviderschema_test.go @@ -8,6 +8,9 @@ import ( "testing" "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-go/tftypes" + "github.com/hashicorp/terraform-plugin-framework/attr" datasourceschema "github.com/hashicorp/terraform-plugin-framework/datasource/schema" "github.com/hashicorp/terraform-plugin-framework/function" @@ -18,8 +21,6 @@ import ( providerschema "github.com/hashicorp/terraform-plugin-framework/provider/schema" resourceschema "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/types" - "github.com/hashicorp/terraform-plugin-go/tfprotov6" - "github.com/hashicorp/terraform-plugin-go/tftypes" ) // TODO: DynamicPseudoType support @@ -1120,15 +1121,12 @@ func TestGetProviderSchemaResponse(t *testing.T) { "testfunction": { Parameters: []*tfprotov6.FunctionParameter{ { - Name: "param1", Type: tftypes.Bool, }, { - Name: "param2", Type: tftypes.Number, }, { - Name: "param3", Type: tftypes.String, }, }, @@ -1202,7 +1200,6 @@ func TestGetProviderSchemaResponse(t *testing.T) { Type: tftypes.String, }, VariadicParameter: &tfprotov6.FunctionParameter{ - Name: function.DefaultVariadicParameterName, Type: tftypes.String, }, }, From c5433f5e84d5dabbe715b0ddcd1daf9890b5a7ff Mon Sep 17 00:00:00 2001 From: Benjamin Bennett Date: Wed, 20 Mar 2024 09:37:42 +0000 Subject: [PATCH 02/12] Passing function name to ValidateImplementation to provide additional context in diagnostics --- function/definition.go | 14 +++++------ function/definition_test.go | 24 +++++++++---------- internal/fwserver/server_functions.go | 2 +- internal/fwserver/server_getfunctions_test.go | 2 +- .../fwserver/server_getproviderschema_test.go | 2 +- 5 files changed, 22 insertions(+), 22 deletions(-) diff --git a/function/definition.go b/function/definition.go index 99a07dfc8..36c53b7be 100644 --- a/function/definition.go +++ b/function/definition.go @@ -89,7 +89,7 @@ func (d Definition) Parameter(ctx context.Context, position int) (Parameter, dia // implementation of the definition to prevent unexpected errors or panics. This // logic runs during the GetProviderSchema RPC, or via provider-defined unit // testing, and should never include false positives. -func (d Definition) ValidateImplementation(ctx context.Context) diag.Diagnostics { +func (d Definition) ValidateImplementation(ctx context.Context, funcName string) diag.Diagnostics { var diags diag.Diagnostics if d.Return == nil { @@ -97,14 +97,14 @@ func (d Definition) ValidateImplementation(ctx context.Context) diag.Diagnostics "Invalid Function Definition", "When validating the function definition, an implementation issue was found. "+ "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ - "Definition Return field is undefined", + fmt.Sprintf("Function %q - Definition Return field is undefined", funcName), ) } else if d.Return.GetType() == nil { diags.AddError( "Invalid Function Definition", "When validating the function definition, an implementation issue was found. "+ "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ - "Definition return data type is undefined", + fmt.Sprintf("Function %q - Definition return data type is undefined", funcName), ) } @@ -117,7 +117,7 @@ func (d Definition) ValidateImplementation(ctx context.Context) diag.Diagnostics "Invalid Function Definition", "When validating the function definition, an implementation issue was found. "+ "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ - fmt.Sprintf("Parameter at position %d does not have a name", pos), + fmt.Sprintf("Function %q - Parameter at position %d does not have a name", funcName, pos), ) } @@ -128,7 +128,7 @@ func (d Definition) ValidateImplementation(ctx context.Context) diag.Diagnostics "When validating the function definition, an implementation issue was found. "+ "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ "Parameter names must be unique. "+ - fmt.Sprintf("Parameters at position %d and %d have the same name %q", conflictPos, pos, name), + fmt.Sprintf("Function %q - Parameters at position %d and %d have the same name %q", funcName, conflictPos, pos, name), ) continue } @@ -144,7 +144,7 @@ func (d Definition) ValidateImplementation(ctx context.Context) diag.Diagnostics "Invalid Function Definition", "When validating the function definition, an implementation issue was found. "+ "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ - "The variadic parameter does not have a name", + fmt.Sprintf("Function %q - The variadic parameter does not have a name", funcName), ) } @@ -155,7 +155,7 @@ func (d Definition) ValidateImplementation(ctx context.Context) diag.Diagnostics "When validating the function definition, an implementation issue was found. "+ "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ "Parameter names must be unique. "+ - fmt.Sprintf("Parameter at position %d and the variadic parameter have the same name %q", conflictPos, name), + fmt.Sprintf("Function %q - Parameter at position %d and the variadic parameter have the same name %q", funcName, conflictPos, name), ) } } diff --git a/function/definition_test.go b/function/definition_test.go index 6f6635ee5..f8b06d2a4 100644 --- a/function/definition_test.go +++ b/function/definition_test.go @@ -168,7 +168,7 @@ func TestDefinitionValidateImplementation(t *testing.T) { "Invalid Function Definition", "When validating the function definition, an implementation issue was found. "+ "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ - "The variadic parameter does not have a name", + "Function \"test-function\" - The variadic parameter does not have a name", ), }, }, @@ -185,13 +185,13 @@ func TestDefinitionValidateImplementation(t *testing.T) { "Invalid Function Definition", "When validating the function definition, an implementation issue was found. "+ "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ - "Parameter at position 0 does not have a name", + "Function \"test-function\" - Parameter at position 0 does not have a name", ), diag.NewErrorDiagnostic( "Invalid Function Definition", "When validating the function definition, an implementation issue was found. "+ "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ - "Parameter at position 1 does not have a name", + "Function \"test-function\" - Parameter at position 1 does not have a name", ), }, }, @@ -208,13 +208,13 @@ func TestDefinitionValidateImplementation(t *testing.T) { "Invalid Function Definition", "When validating the function definition, an implementation issue was found. "+ "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ - "Parameter at position 0 does not have a name", + "Function \"test-function\" - Parameter at position 0 does not have a name", ), diag.NewErrorDiagnostic( "Invalid Function Definition", "When validating the function definition, an implementation issue was found. "+ "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ - "The variadic parameter does not have a name", + "Function \"test-function\" - The variadic parameter does not have a name", ), }, }, @@ -225,7 +225,7 @@ func TestDefinitionValidateImplementation(t *testing.T) { "Invalid Function Definition", "When validating the function definition, an implementation issue was found. "+ "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ - "Definition Return field is undefined", + "Function \"test-function\" - Definition Return field is undefined", ), }, }, @@ -256,7 +256,7 @@ func TestDefinitionValidateImplementation(t *testing.T) { "When validating the function definition, an implementation issue was found. "+ "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ "Parameter names must be unique. "+ - "Parameters at position 2 and 4 have the same name \"param-dup\"", + "Function \"test-function\" - Parameters at position 2 and 4 have the same name \"param-dup\"", ), }, }, @@ -284,7 +284,7 @@ func TestDefinitionValidateImplementation(t *testing.T) { "When validating the function definition, an implementation issue was found. "+ "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ "Parameter names must be unique. "+ - "Parameter at position 1 and the variadic parameter have the same name \"param-dup\"", + "Function \"test-function\" - Parameter at position 1 and the variadic parameter have the same name \"param-dup\"", ), }, }, @@ -318,21 +318,21 @@ func TestDefinitionValidateImplementation(t *testing.T) { "When validating the function definition, an implementation issue was found. "+ "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ "Parameter names must be unique. "+ - "Parameters at position 0 and 2 have the same name \"param-dup\"", + "Function \"test-function\" - Parameters at position 0 and 2 have the same name \"param-dup\"", ), diag.NewErrorDiagnostic( "Invalid Function Definition", "When validating the function definition, an implementation issue was found. "+ "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ "Parameter names must be unique. "+ - "Parameters at position 0 and 4 have the same name \"param-dup\"", + "Function \"test-function\" - Parameters at position 0 and 4 have the same name \"param-dup\"", ), diag.NewErrorDiagnostic( "Invalid Function Definition", "When validating the function definition, an implementation issue was found. "+ "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ "Parameter names must be unique. "+ - "Parameter at position 0 and the variadic parameter have the same name \"param-dup\"", + "Function \"test-function\" - Parameter at position 0 and the variadic parameter have the same name \"param-dup\"", ), }, }, @@ -344,7 +344,7 @@ func TestDefinitionValidateImplementation(t *testing.T) { t.Run(name, func(t *testing.T) { t.Parallel() - got := testCase.definition.ValidateImplementation(context.Background()) + got := testCase.definition.ValidateImplementation(context.Background(), "test-function") if diff := cmp.Diff(got, testCase.expected); diff != "" { t.Errorf("unexpected difference: %s", diff) diff --git a/internal/fwserver/server_functions.go b/internal/fwserver/server_functions.go index c8e6142d2..e6d2c2014 100644 --- a/internal/fwserver/server_functions.go +++ b/internal/fwserver/server_functions.go @@ -98,7 +98,7 @@ func (s *Server) FunctionDefinitions(ctx context.Context) (map[string]function.D continue } - validateDiags := definitionResp.Definition.ValidateImplementation(ctx) + validateDiags := definitionResp.Definition.ValidateImplementation(ctx, name) diags.Append(validateDiags...) diff --git a/internal/fwserver/server_getfunctions_test.go b/internal/fwserver/server_getfunctions_test.go index 8aae0b625..1df383710 100644 --- a/internal/fwserver/server_getfunctions_test.go +++ b/internal/fwserver/server_getfunctions_test.go @@ -116,7 +116,7 @@ func TestServerGetFunctions(t *testing.T) { "Invalid Function Definition", "When validating the function definition, an implementation issue was found. "+ "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ - "Definition Return field is undefined", + "Function \"function1\" - Definition Return field is undefined", ), }, FunctionDefinitions: map[string]function.Definition{}, diff --git a/internal/fwserver/server_getproviderschema_test.go b/internal/fwserver/server_getproviderschema_test.go index edd2c0acf..3c975d11b 100644 --- a/internal/fwserver/server_getproviderschema_test.go +++ b/internal/fwserver/server_getproviderschema_test.go @@ -416,7 +416,7 @@ func TestServerGetProviderSchema(t *testing.T) { "Invalid Function Definition", "When validating the function definition, an implementation issue was found. "+ "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ - "Definition Return field is undefined", + "Function \"function1\" - Definition Return field is undefined", ), }, FunctionDefinitions: nil, From 29c49aef6b7e151b34b49f4c7ff2301953c34921 Mon Sep 17 00:00:00 2001 From: Benjamin Bennett Date: Wed, 20 Mar 2024 09:37:56 +0000 Subject: [PATCH 03/12] Add nil check for Error() method on function error --- function/func_error.go | 4 ++++ function/func_error_test.go | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/function/func_error.go b/function/func_error.go index 78da0053b..4ce870a2f 100644 --- a/function/func_error.go +++ b/function/func_error.go @@ -69,6 +69,10 @@ func (fe *FuncError) Equal(other *FuncError) bool { // Error returns the error text. func (fe *FuncError) Error() string { + if fe == nil { + return "" + } + return fe.Text } diff --git a/function/func_error_test.go b/function/func_error_test.go index b5e7816c7..6232071e0 100644 --- a/function/func_error_test.go +++ b/function/func_error_test.go @@ -87,6 +87,42 @@ func TestFunctionError_Equal(t *testing.T) { } } +func TestFunctionError_Error(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + funcErr *function.FuncError + expected string + }{ + "nil": { + expected: "", + }, + "empty": { + funcErr: &function.FuncError{}, + expected: "", + }, + "text": { + funcErr: &function.FuncError{ + Text: "function error", + }, + expected: "function error", + }, + } + + for name, tc := range testCases { + name, tc := name, tc + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := tc.funcErr.Error() + + if got != tc.expected { + t.Errorf("Unexpected response: got: %s, wanted: %s", got, tc.expected) + } + }) + } +} + func TestConcatFuncErrors(t *testing.T) { t.Parallel() From fb236ec353870a48d531dfea0d1a048223cb1aea Mon Sep 17 00:00:00 2001 From: Benjamin Bennett Date: Wed, 20 Mar 2024 09:52:19 +0000 Subject: [PATCH 04/12] Adding changelog entry --- .changes/unreleased/BREAKING CHANGES-20240320-095152.yaml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changes/unreleased/BREAKING CHANGES-20240320-095152.yaml diff --git a/.changes/unreleased/BREAKING CHANGES-20240320-095152.yaml b/.changes/unreleased/BREAKING CHANGES-20240320-095152.yaml new file mode 100644 index 000000000..0e8a33091 --- /dev/null +++ b/.changes/unreleased/BREAKING CHANGES-20240320-095152.yaml @@ -0,0 +1,5 @@ +kind: BREAKING CHANGES +body: 'function: All parameters must be explicitly named' +time: 2024-03-20T09:51:52.869254Z +custom: + Issue: "964" From 5a9e5e87f50d038b7df79c04f4292ad000b27fac Mon Sep 17 00:00:00 2001 From: Benjamin Bennett Date: Wed, 20 Mar 2024 15:49:24 +0000 Subject: [PATCH 05/12] Update .changes/unreleased/BREAKING CHANGES-20240320-095152.yaml Co-authored-by: Brian Flad --- .changes/unreleased/BREAKING CHANGES-20240320-095152.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changes/unreleased/BREAKING CHANGES-20240320-095152.yaml b/.changes/unreleased/BREAKING CHANGES-20240320-095152.yaml index 0e8a33091..4e2a25450 100644 --- a/.changes/unreleased/BREAKING CHANGES-20240320-095152.yaml +++ b/.changes/unreleased/BREAKING CHANGES-20240320-095152.yaml @@ -1,5 +1,5 @@ kind: BREAKING CHANGES -body: 'function: All parameters must be explicitly named' +body: 'function: All parameters must be explicitly named via the `Name` field' time: 2024-03-20T09:51:52.869254Z custom: Issue: "964" From 11433b97e4a99d6bf959f079c78ceecd21c22fd9 Mon Sep 17 00:00:00 2001 From: Benjamin Bennett Date: Wed, 20 Mar 2024 16:29:39 +0000 Subject: [PATCH 06/12] Switching to request-response pattern for function definition validation --- function/definition.go | 34 +++-- function/definition_test.go | 177 ++++++++++++++------------ internal/fwserver/server_functions.go | 12 +- 3 files changed, 132 insertions(+), 91 deletions(-) diff --git a/function/definition.go b/function/definition.go index 36c53b7be..ea8699266 100644 --- a/function/definition.go +++ b/function/definition.go @@ -89,7 +89,7 @@ func (d Definition) Parameter(ctx context.Context, position int) (Parameter, dia // implementation of the definition to prevent unexpected errors or panics. This // logic runs during the GetProviderSchema RPC, or via provider-defined unit // testing, and should never include false positives. -func (d Definition) ValidateImplementation(ctx context.Context, funcName string) diag.Diagnostics { +func (d Definition) ValidateImplementation(ctx context.Context, req DefinitionValidateRequest, resp *DefinitionValidateResponse) { var diags diag.Diagnostics if d.Return == nil { @@ -97,14 +97,14 @@ func (d Definition) ValidateImplementation(ctx context.Context, funcName string) "Invalid Function Definition", "When validating the function definition, an implementation issue was found. "+ "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ - fmt.Sprintf("Function %q - Definition Return field is undefined", funcName), + fmt.Sprintf("Function %q - Definition Return field is undefined", req.FuncName), ) } else if d.Return.GetType() == nil { diags.AddError( "Invalid Function Definition", "When validating the function definition, an implementation issue was found. "+ "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ - fmt.Sprintf("Function %q - Definition return data type is undefined", funcName), + fmt.Sprintf("Function %q - Definition return data type is undefined", req.FuncName), ) } @@ -117,7 +117,7 @@ func (d Definition) ValidateImplementation(ctx context.Context, funcName string) "Invalid Function Definition", "When validating the function definition, an implementation issue was found. "+ "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ - fmt.Sprintf("Function %q - Parameter at position %d does not have a name", funcName, pos), + fmt.Sprintf("Function %q - Parameter at position %d does not have a name", req.FuncName, pos), ) } @@ -128,7 +128,7 @@ func (d Definition) ValidateImplementation(ctx context.Context, funcName string) "When validating the function definition, an implementation issue was found. "+ "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ "Parameter names must be unique. "+ - fmt.Sprintf("Function %q - Parameters at position %d and %d have the same name %q", funcName, conflictPos, pos, name), + fmt.Sprintf("Function %q - Parameters at position %d and %d have the same name %q", req.FuncName, conflictPos, pos, name), ) continue } @@ -144,7 +144,7 @@ func (d Definition) ValidateImplementation(ctx context.Context, funcName string) "Invalid Function Definition", "When validating the function definition, an implementation issue was found. "+ "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ - fmt.Sprintf("Function %q - The variadic parameter does not have a name", funcName), + fmt.Sprintf("Function %q - The variadic parameter does not have a name", req.FuncName), ) } @@ -155,12 +155,12 @@ func (d Definition) ValidateImplementation(ctx context.Context, funcName string) "When validating the function definition, an implementation issue was found. "+ "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ "Parameter names must be unique. "+ - fmt.Sprintf("Function %q - Parameter at position %d and the variadic parameter have the same name %q", funcName, conflictPos, name), + fmt.Sprintf("Function %q - Parameter at position %d and the variadic parameter have the same name %q", req.FuncName, conflictPos, name), ) } } - return diags + resp.Diagnostics.Append(diags...) } // DefinitionRequest represents a request for the Function to return its @@ -180,3 +180,21 @@ type DefinitionResponse struct { // An empty slice indicates success, with no warnings or errors generated. Diagnostics diag.Diagnostics } + +// DefinitionValidateRequest represents a request for the Function to validate its +// definition. An instance of this request struct is supplied as an argument to +// the Definition type ValidateImplementation method. +type DefinitionValidateRequest struct { + // FuncName is the name of the function definition being validated. + FuncName string +} + +// DefinitionValidateResponse represents a response to a DefinitionValidateRequest. +// An instance of this response struct is supplied as an argument to the Definition +// type ValidateImplementation method. +type DefinitionValidateResponse struct { + // Diagnostics report errors or warnings related to validation of a function + // definition. An empty slice indicates success, with no warnings or errors + // generated. + Diagnostics diag.Diagnostics +} diff --git a/function/definition_test.go b/function/definition_test.go index f8b06d2a4..46be02ea4 100644 --- a/function/definition_test.go +++ b/function/definition_test.go @@ -151,25 +151,28 @@ func TestDefinitionValidateImplementation(t *testing.T) { testCases := map[string]struct { definition function.Definition - expected diag.Diagnostics + expected function.DefinitionValidateResponse }{ "valid-no-params": { definition: function.Definition{ Return: function.StringReturn{}, }, + expected: function.DefinitionValidateResponse{}, }, "missing-variadic-param-name": { definition: function.Definition{ VariadicParameter: function.StringParameter{}, Return: function.StringReturn{}, }, - expected: diag.Diagnostics{ - diag.NewErrorDiagnostic( - "Invalid Function Definition", - "When validating the function definition, an implementation issue was found. "+ - "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ - "Function \"test-function\" - The variadic parameter does not have a name", - ), + expected: function.DefinitionValidateResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Function Definition", + "When validating the function definition, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "Function \"test-function\" - The variadic parameter does not have a name", + ), + }, }, }, "missing-param-names": { @@ -180,19 +183,21 @@ func TestDefinitionValidateImplementation(t *testing.T) { }, Return: function.StringReturn{}, }, - expected: diag.Diagnostics{ - diag.NewErrorDiagnostic( - "Invalid Function Definition", - "When validating the function definition, an implementation issue was found. "+ - "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ - "Function \"test-function\" - Parameter at position 0 does not have a name", - ), - diag.NewErrorDiagnostic( - "Invalid Function Definition", - "When validating the function definition, an implementation issue was found. "+ - "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ - "Function \"test-function\" - Parameter at position 1 does not have a name", - ), + expected: function.DefinitionValidateResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Function Definition", + "When validating the function definition, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "Function \"test-function\" - Parameter at position 0 does not have a name", + ), + diag.NewErrorDiagnostic( + "Invalid Function Definition", + "When validating the function definition, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "Function \"test-function\" - Parameter at position 1 does not have a name", + ), + }, }, }, "missing-param-names-with-variadic": { @@ -203,30 +208,34 @@ func TestDefinitionValidateImplementation(t *testing.T) { VariadicParameter: function.NumberParameter{}, Return: function.StringReturn{}, }, - expected: diag.Diagnostics{ - diag.NewErrorDiagnostic( - "Invalid Function Definition", - "When validating the function definition, an implementation issue was found. "+ - "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ - "Function \"test-function\" - Parameter at position 0 does not have a name", - ), - diag.NewErrorDiagnostic( - "Invalid Function Definition", - "When validating the function definition, an implementation issue was found. "+ - "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ - "Function \"test-function\" - The variadic parameter does not have a name", - ), + expected: function.DefinitionValidateResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Function Definition", + "When validating the function definition, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "Function \"test-function\" - Parameter at position 0 does not have a name", + ), + diag.NewErrorDiagnostic( + "Invalid Function Definition", + "When validating the function definition, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "Function \"test-function\" - The variadic parameter does not have a name", + ), + }, }, }, "result-missing": { definition: function.Definition{}, - expected: diag.Diagnostics{ - diag.NewErrorDiagnostic( - "Invalid Function Definition", - "When validating the function definition, an implementation issue was found. "+ - "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ - "Function \"test-function\" - Definition Return field is undefined", - ), + expected: function.DefinitionValidateResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Function Definition", + "When validating the function definition, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "Function \"test-function\" - Definition Return field is undefined", + ), + }, }, }, "conflicting-param-names": { @@ -250,14 +259,16 @@ func TestDefinitionValidateImplementation(t *testing.T) { }, Return: function.StringReturn{}, }, - expected: diag.Diagnostics{ - diag.NewErrorDiagnostic( - "Invalid Function Definition", - "When validating the function definition, an implementation issue was found. "+ - "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ - "Parameter names must be unique. "+ - "Function \"test-function\" - Parameters at position 2 and 4 have the same name \"param-dup\"", - ), + expected: function.DefinitionValidateResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Function Definition", + "When validating the function definition, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "Parameter names must be unique. "+ + "Function \"test-function\" - Parameters at position 2 and 4 have the same name \"param-dup\"", + ), + }, }, }, "conflicting-param-names-variadic": { @@ -278,14 +289,16 @@ func TestDefinitionValidateImplementation(t *testing.T) { }, Return: function.StringReturn{}, }, - expected: diag.Diagnostics{ - diag.NewErrorDiagnostic( - "Invalid Function Definition", - "When validating the function definition, an implementation issue was found. "+ - "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ - "Parameter names must be unique. "+ - "Function \"test-function\" - Parameter at position 1 and the variadic parameter have the same name \"param-dup\"", - ), + expected: function.DefinitionValidateResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Function Definition", + "When validating the function definition, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "Parameter names must be unique. "+ + "Function \"test-function\" - Parameter at position 1 and the variadic parameter have the same name \"param-dup\"", + ), + }, }, }, "conflicting-param-names-variadic-multiple": { @@ -312,28 +325,30 @@ func TestDefinitionValidateImplementation(t *testing.T) { }, Return: function.StringReturn{}, }, - expected: diag.Diagnostics{ - diag.NewErrorDiagnostic( - "Invalid Function Definition", - "When validating the function definition, an implementation issue was found. "+ - "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ - "Parameter names must be unique. "+ - "Function \"test-function\" - Parameters at position 0 and 2 have the same name \"param-dup\"", - ), - diag.NewErrorDiagnostic( - "Invalid Function Definition", - "When validating the function definition, an implementation issue was found. "+ - "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ - "Parameter names must be unique. "+ - "Function \"test-function\" - Parameters at position 0 and 4 have the same name \"param-dup\"", - ), - diag.NewErrorDiagnostic( - "Invalid Function Definition", - "When validating the function definition, an implementation issue was found. "+ - "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ - "Parameter names must be unique. "+ - "Function \"test-function\" - Parameter at position 0 and the variadic parameter have the same name \"param-dup\"", - ), + expected: function.DefinitionValidateResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Function Definition", + "When validating the function definition, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "Parameter names must be unique. "+ + "Function \"test-function\" - Parameters at position 0 and 2 have the same name \"param-dup\"", + ), + diag.NewErrorDiagnostic( + "Invalid Function Definition", + "When validating the function definition, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "Parameter names must be unique. "+ + "Function \"test-function\" - Parameters at position 0 and 4 have the same name \"param-dup\"", + ), + diag.NewErrorDiagnostic( + "Invalid Function Definition", + "When validating the function definition, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "Parameter names must be unique. "+ + "Function \"test-function\" - Parameter at position 0 and the variadic parameter have the same name \"param-dup\"", + ), + }, }, }, } @@ -344,7 +359,9 @@ func TestDefinitionValidateImplementation(t *testing.T) { t.Run(name, func(t *testing.T) { t.Parallel() - got := testCase.definition.ValidateImplementation(context.Background(), "test-function") + got := function.DefinitionValidateResponse{} + + testCase.definition.ValidateImplementation(context.Background(), function.DefinitionValidateRequest{FuncName: "test-function"}, &got) if diff := cmp.Diff(got, testCase.expected); diff != "" { t.Errorf("unexpected difference: %s", diff) diff --git a/internal/fwserver/server_functions.go b/internal/fwserver/server_functions.go index e6d2c2014..37c87e7c8 100644 --- a/internal/fwserver/server_functions.go +++ b/internal/fwserver/server_functions.go @@ -98,11 +98,17 @@ func (s *Server) FunctionDefinitions(ctx context.Context) (map[string]function.D continue } - validateDiags := definitionResp.Definition.ValidateImplementation(ctx, name) + validateReq := function.DefinitionValidateRequest{ + FuncName: name, + } + + validateResp := function.DefinitionValidateResponse{} + + definitionResp.Definition.ValidateImplementation(ctx, validateReq, &validateResp) - diags.Append(validateDiags...) + diags.Append(validateResp.Diagnostics...) - if validateDiags.HasError() { + if validateResp.Diagnostics.HasError() { continue } From e2ae3cd385358ce8852bbb3b2c58c180293040b0 Mon Sep 17 00:00:00 2001 From: Benjamin Bennett Date: Wed, 20 Mar 2024 17:16:10 +0000 Subject: [PATCH 07/12] Adding change log entry for removal of DefaultParameterNamePrefix and DefaultVariadicParameterName constants --- .changes/unreleased/BREAKING CHANGES-20240320-171454.yaml | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changes/unreleased/BREAKING CHANGES-20240320-171454.yaml diff --git a/.changes/unreleased/BREAKING CHANGES-20240320-171454.yaml b/.changes/unreleased/BREAKING CHANGES-20240320-171454.yaml new file mode 100644 index 000000000..0e89a43fb --- /dev/null +++ b/.changes/unreleased/BREAKING CHANGES-20240320-171454.yaml @@ -0,0 +1,6 @@ +kind: BREAKING CHANGES +body: 'function: `DefaultParameterNamePrefix` and `DefaultVariadicParameterName` constants + have been removed' +time: 2024-03-20T17:14:54.480412Z +custom: + Issue: "964" From 66e4fa19435bef2d4b22946cee802ae0fe7d4884 Mon Sep 17 00:00:00 2001 From: Benjamin Bennett Date: Wed, 20 Mar 2024 18:01:32 +0000 Subject: [PATCH 08/12] Using parameter names throughout examples, and adding illustration of potential runtime errors --- .../framework/functions/documentation.mdx | 10 ++--- .../framework/functions/implementation.mdx | 39 +++++++++++++++---- .../framework/functions/parameters/bool.mdx | 5 ++- .../functions/parameters/float64.mdx | 6 ++- .../framework/functions/parameters/index.mdx | 33 ++++++++++++++++ .../framework/functions/parameters/int64.mdx | 6 ++- .../framework/functions/parameters/list.mdx | 2 + .../framework/functions/parameters/map.mdx | 3 ++ .../framework/functions/parameters/number.mdx | 6 ++- .../framework/functions/parameters/object.mdx | 2 + .../framework/functions/parameters/set.mdx | 3 ++ .../framework/functions/parameters/string.mdx | 6 ++- 12 files changed, 103 insertions(+), 18 deletions(-) diff --git a/website/docs/plugin/framework/functions/documentation.mdx b/website/docs/plugin/framework/functions/documentation.mdx index 91b1c293c..c7e7ec373 100644 --- a/website/docs/plugin/framework/functions/documentation.mdx +++ b/website/docs/plugin/framework/functions/documentation.mdx @@ -45,11 +45,11 @@ func (f *CidrContainsIpFunction) Definition(ctx context.Context, req function.De Each [parameter type](/terraform/plugin/framework/functions/parameters), whether in the definition `Parameters` or `VariadicParameter` field, implements the following fields: -| Field Name | Description | -|---|---| -| `Name` | Single word or abbreviation of parameter for function signature generation. If name is not provided, will default to the prefix "param" with a suffix of the position the parameter is in the function definition (e.g., `param1`, `param2`). If the parameter is variadic, the default name will be `varparam`. | -| `Description` | Documentation about the parameter and its expected values in plaintext format. | -| `MarkdownDescription` | Documentation about the parameter and its expected values in Markdown format. | +| Field Name | Description | +|-----------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------| +| `Name` | **Required**: Single word or abbreviation of parameter for function signature generation. If name is not provided, a runtime error will be generated. | +| `Description` | Documentation about the parameter and its expected values in plaintext format. | +| `MarkdownDescription` | Documentation about the parameter and its expected values in Markdown format. | The name must be unique in the context of the [function definition](/terraform/plugin/framework/functions/implementation#definition-method). It is used for documentation purposes and displayed in error diagnostics presented to practitioners. The name should delineate the purpose of the parameter, especially to disambiguate between multiple parameters, such as the words `cidr` and `ip` in a generated function signature like `cidr_contains_ip(cidr string, ip string) bool`. diff --git a/website/docs/plugin/framework/functions/implementation.mdx b/website/docs/plugin/framework/functions/implementation.mdx index 9fe731837..79ffb1a51 100644 --- a/website/docs/plugin/framework/functions/implementation.mdx +++ b/website/docs/plugin/framework/functions/implementation.mdx @@ -152,8 +152,14 @@ func (f *ExampleFunction) Definition(ctx context.Context, req function.Definitio resp.Definition = function.Definition{ // ... other fields ... Parameters: []function.Parameter{ - function.BoolParameter{}, - function.StringParameter{}, + function.BoolParameter{ + Name: "bool_param", + // ... other fields ... + }, + function.StringParameter{ + Name: "string_param", + // ... other fields ... + }, }, } } @@ -177,8 +183,14 @@ func (f *ExampleFunction) Definition(ctx context.Context, req function.Definitio resp.Definition = function.Definition{ // ... other fields ... Parameters: []function.Parameter{ - function.BoolParameter{}, - function.StringParameter{}, + function.BoolParameter{ + Name: "bool_param", + // ... other fields ... + }, + function.StringParameter{ + Name: "string_param", + // ... other fields ... + }, }, } } @@ -205,10 +217,14 @@ func (f *ExampleFunction) Definition(ctx context.Context, req function.Definitio resp.Definition = function.Definition{ // ... other fields ... Parameters: []function.Parameter{ - function.BoolParameter{}, + function.BoolParameter{ + Name: "bool_param", + // ... other fields ... + }, }, VariadicParameter: function.StringParameter{ Name: "variadic_param", + // ... other fields ... }, } } @@ -232,12 +248,19 @@ func (f *ExampleFunction) Definition(ctx context.Context, req function.Definitio resp.Definition = function.Definition{ // ... other fields ... Parameters: []function.Parameter{ - function.BoolParameter{}, - function.Int64Parameter{}, + function.BoolParameter{ + Name: "bool_param", + // ... other fields ... + }, + function.Int64Parameter{ + Name: "int64_param", + // ... other fields ... + }, }, VariadicParameter: function.StringParameter{ Name: "variadic_param", - }, + // ... other fields ... + }, } } diff --git a/website/docs/plugin/framework/functions/parameters/bool.mdx b/website/docs/plugin/framework/functions/parameters/bool.mdx index 7607f1bb3..7e58f771f 100644 --- a/website/docs/plugin/framework/functions/parameters/bool.mdx +++ b/website/docs/plugin/framework/functions/parameters/bool.mdx @@ -26,6 +26,7 @@ func (f ExampleFunction) Definition(ctx context.Context, req function.Definition // ... other Definition fields ... Parameters: []function.Parameter{ function.BoolParameter{ + Name: "example", // ... potentially other BoolParameter fields ... }, }, @@ -73,7 +74,9 @@ func (f ExampleFunction) Definition(ctx context.Context, req function.Definition resp.Definition = function.Definition{ // ... other Definition fields ... Parameters: []function.Parameter{ - function.BoolParameter{}, + function.BoolParameter{ + Name: "bool_param", + }, }, } } diff --git a/website/docs/plugin/framework/functions/parameters/float64.mdx b/website/docs/plugin/framework/functions/parameters/float64.mdx index d9b26196d..11f97239f 100644 --- a/website/docs/plugin/framework/functions/parameters/float64.mdx +++ b/website/docs/plugin/framework/functions/parameters/float64.mdx @@ -32,6 +32,7 @@ func (f ExampleFunction) Definition(ctx context.Context, req function.Definition // ... other Definition fields ... Parameters: []function.Parameter{ function.Float64Parameter{ + Name: "float64_param", // ... potentially other Float64Parameter fields ... }, }, @@ -79,7 +80,10 @@ func (f ExampleFunction) Definition(ctx context.Context, req function.Definition resp.Definition = function.Definition{ // ... other Definition fields ... Parameters: []function.Parameter{ - function.Float64Parameter{}, + function.Float64Parameter{ + Name: "float64_param", + // ... potentially other Float64Parameter fields ... + }, }, } } diff --git a/website/docs/plugin/framework/functions/parameters/index.mdx b/website/docs/plugin/framework/functions/parameters/index.mdx index ea15984bf..445d2b234 100644 --- a/website/docs/plugin/framework/functions/parameters/index.mdx +++ b/website/docs/plugin/framework/functions/parameters/index.mdx @@ -46,3 +46,36 @@ Parameter type that accepts a structure of explicit attribute names. | Parameter Type | Use Case | |----------------|----------| | [Object](/terraform/plugin/framework/functions/parameters/object) | Single structure mapping explicit attribute names | + +## Parameter Naming + +All parameter types have a `Name` field that is **required**. + +### Missing Parameter Names + +Attempting to use unnamed parameters will generate runtime errors of the following form: + +```shell +│ Error: Failed to load plugin schemas +│ +│ Error while loading schemas for plugin components: Failed to obtain provider schema: Could not load the schema for provider registry.terraform.io/cloud_provider/cloud_resource: failed to +│ retrieve schema from provider "registry.terraform.io/cloud_provider/cloud_resource": Invalid Function Definition: When validating the function definition, an implementation issue was +│ found. This is always an issue with the provider and should be reported to the provider developers. +│ +│ Function "example_function" - Parameter at position 0 does not have a name. +``` + +### Parameter Errors + +Parameter names are used in runtime errors to highlight which parameter is causing the issue. For example, using a value that is incompatible with the parameter type will generate an error message such as the following: + +```shell +│ Error: Invalid function argument +│ +│ on resource.tf line 10, in resource "example_resource" "example": +│ 10: configurable_attribute = provider::example::example_function("string") +│ ├──────────────── +│ │ while calling provider::example::example_function(bool_param) +│ +│ Invalid value for "bool_param" parameter: a bool is required. +``` \ No newline at end of file diff --git a/website/docs/plugin/framework/functions/parameters/int64.mdx b/website/docs/plugin/framework/functions/parameters/int64.mdx index e09432547..ab3b272d2 100644 --- a/website/docs/plugin/framework/functions/parameters/int64.mdx +++ b/website/docs/plugin/framework/functions/parameters/int64.mdx @@ -32,6 +32,7 @@ func (f ExampleFunction) Definition(ctx context.Context, req function.Definition // ... other Definition fields ... Parameters: []function.Parameter{ function.Int64Parameter{ + Name: "int64_param", // ... potentially other Int64Parameter fields ... }, }, @@ -79,7 +80,10 @@ func (f ExampleFunction) Definition(ctx context.Context, req function.Definition resp.Definition = function.Definition{ // ... other Definition fields ... Parameters: []function.Parameter{ - function.Int64Parameter{}, + function.Int64Parameter{ + Name: "int64_param", + // ... potentially other Int64Parameter fields ... + }, }, } } diff --git a/website/docs/plugin/framework/functions/parameters/list.mdx b/website/docs/plugin/framework/functions/parameters/list.mdx index 9109ba680..30da0e91a 100644 --- a/website/docs/plugin/framework/functions/parameters/list.mdx +++ b/website/docs/plugin/framework/functions/parameters/list.mdx @@ -29,6 +29,7 @@ func (f ExampleFunction) Definition(ctx context.Context, req function.Definition Parameters: []function.Parameter{ function.ListParameter{ ElementType: types.StringType, + Name: "list_param", // ... potentially other ListParameter fields ... }, }, @@ -83,6 +84,7 @@ func (f ExampleFunction) Definition(ctx context.Context, req function.Definition Parameters: []function.Parameter{ function.ListParameter{ ElementType: types.StringType, + Name: "list_param", }, }, } diff --git a/website/docs/plugin/framework/functions/parameters/map.mdx b/website/docs/plugin/framework/functions/parameters/map.mdx index 158a2612b..8eda096fa 100644 --- a/website/docs/plugin/framework/functions/parameters/map.mdx +++ b/website/docs/plugin/framework/functions/parameters/map.mdx @@ -32,6 +32,7 @@ func (f ExampleFunction) Definition(ctx context.Context, req function.Definition Parameters: []function.Parameter{ function.MapParameter{ ElementType: types.StringType, + Name: "map_param", // ... potentially other MapParameter fields ... }, }, @@ -86,6 +87,8 @@ func (f ExampleFunction) Definition(ctx context.Context, req function.Definition Parameters: []function.Parameter{ function.MapParameter{ ElementType: types.StringType, + Name: "map_param", + // ... potentially other MapParameter fields ... }, }, } diff --git a/website/docs/plugin/framework/functions/parameters/number.mdx b/website/docs/plugin/framework/functions/parameters/number.mdx index f97a40289..b5e30fb70 100644 --- a/website/docs/plugin/framework/functions/parameters/number.mdx +++ b/website/docs/plugin/framework/functions/parameters/number.mdx @@ -32,6 +32,7 @@ func (f ExampleFunction) Definition(ctx context.Context, req function.Definition // ... other Definition fields ... Parameters: []function.Parameter{ function.NumberParameter{ + Name: "number_param", // ... potentially other NumberParameter fields ... }, }, @@ -78,7 +79,10 @@ func (f ExampleFunction) Definition(ctx context.Context, req function.Definition resp.Definition = function.Definition{ // ... other Definition fields ... Parameters: []function.Parameter{ - function.NumberParameter{}, + function.NumberParameter{ + Name: "number_param", + // ... potentially other NumberParameter fields ... + }, }, } } diff --git a/website/docs/plugin/framework/functions/parameters/object.mdx b/website/docs/plugin/framework/functions/parameters/object.mdx index edd2443ed..dd478f854 100644 --- a/website/docs/plugin/framework/functions/parameters/object.mdx +++ b/website/docs/plugin/framework/functions/parameters/object.mdx @@ -39,6 +39,7 @@ func (f ExampleFunction) Definition(ctx context.Context, req function.Definition "attr2": types.Int64Type, "attr3": types.BoolType, }, + Name: "object_param", // ... potentially other ObjectParameter fields ... }, }, @@ -98,6 +99,7 @@ func (f ExampleFunction) Definition(ctx context.Context, req function.Definition "attr2": types.Int64Type, "attr3": types.BoolType, }, + Name: "object_param", }, }, } diff --git a/website/docs/plugin/framework/functions/parameters/set.mdx b/website/docs/plugin/framework/functions/parameters/set.mdx index dfef8490c..ccd8117c5 100644 --- a/website/docs/plugin/framework/functions/parameters/set.mdx +++ b/website/docs/plugin/framework/functions/parameters/set.mdx @@ -29,6 +29,7 @@ func (f ExampleFunction) Definition(ctx context.Context, req function.Definition Parameters: []function.Parameter{ function.SetParameter{ ElementType: types.StringType, + Name: "set_param", // ... potentially other SetParameter fields ... }, }, @@ -83,6 +84,8 @@ func (f ExampleFunction) Definition(ctx context.Context, req function.Definition Parameters: []function.Parameter{ function.SetParameter{ ElementType: types.StringType, + Name: "set_param", + // ... potentially other SetParameter fields ... }, }, } diff --git a/website/docs/plugin/framework/functions/parameters/string.mdx b/website/docs/plugin/framework/functions/parameters/string.mdx index 5f04df8af..c11e33b5c 100644 --- a/website/docs/plugin/framework/functions/parameters/string.mdx +++ b/website/docs/plugin/framework/functions/parameters/string.mdx @@ -26,6 +26,7 @@ func (f ExampleFunction) Definition(ctx context.Context, req function.Definition // ... other Definition fields ... Parameters: []function.Parameter{ function.StringParameter{ + Name: "string_param", // ... potentially other StringParameter fields ... }, }, @@ -73,7 +74,10 @@ func (f ExampleFunction) Definition(ctx context.Context, req function.Definition resp.Definition = function.Definition{ // ... other Definition fields ... Parameters: []function.Parameter{ - function.StringParameter{}, + function.StringParameter{ + Name: "string_param", + // ... potentially other StringParameter fields ... + }, }, } } From 1ded09e7e6e735e9608b1b5d25fdd82d07f78c91 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Wed, 20 Mar 2024 17:36:26 -0400 Subject: [PATCH 09/12] all: Add dynamic type, attribute, and function support (#931) * initial implementation of dynamic type and value * fix doc string * add dynamic defaults * add plan modifier for dynamic * add dynamic attribute, validator, and plan modifiers * add provider and data source attributes and tests * update comment * add resource schema attribute test * add provider metaschema * add hook into attribute validation * add hook into plan modification for resources * add hook into dynamic default logic * add hooks for dynamic semantic equality * add function param and return definitions * add function tests * modify ToTerraformValue to always return DPT * switch back * make reflection update * update dep * use safe equal * return attr.Type * add todos and docs based on findings * random doc cleanup * doc fix * fix for data path * switch to tuples and add reflection support * update docs * add names to variadic param names * update doc note * update docs * update docs * add doc note * add changelog * delete metaschema and fix broken tests * updated some docs * switch changelog to breaking change * update to tuple index * update comment wording * update doc * add new maintainer note * remove top part of comment * add logic to look at value type for dynamic schemas * add data value tests * add dynamic get at path tests * add dynamic get tests * add path exist tests * add get provider schema tests * add comment to weird obj fix * make changes to support reflection (get) * function: Switch the representation of variadic arguments to `types.Tuple` (#923) * switch to tuples and add reflection support * update docs * add names to variadic param names * update doc note * update docs * update docs * add doc note * add changelog * switch changelog to breaking change * update to tuple index * update comment wording * update doc * add new maintainer note * remove top part of comment * fixed the odd spacing in the comment :) * add dynamic interface * reflection test * add list type update * add list value updates * small refactoring * quick check * revert list type and value * add detailed comments about the lack of dynamic element type support * add some object tests for good measure * add tuple tests * add validation logic for list attribute only * add block and attribute validation for list * function: Replace usage of diagnostics with function errors during execution of provider-defined functions (#925) * Replacing function.RunResponse diagnostics with error * Adding custom FunctionError * Adding custom FunctionErrors * Removing unneeded equateErrors gocmp option * Switching to using convenience functions for adding errors to FunctionErrors * Add copyright headers * Refactor to use Error() method on function errors when converting to tfprotov5/6.FunctionError * Adding documentation and testing for fwerrors types * Formatting errors during conversion to tfprotov<5|6>.FunctionError * Removing argument error and argument warning diagnostics * Renaming field name for FunctionErrors from Error to Errors * Modifying documentation to reflect that executing the Run() method of a provider-defined function returns FunctionErrors * Remove references to AddArgumentError and AddArgumentWarning from diagnostics documentation * Removing fwerror package and moving FunctionError to function package * Refactoring to replace FunctionErrors slice with single FunctionError * Bumping terraform-plugin-go to v0.22.0 * Removing unneeded DiagnosticWithFunctionArgument interface and implementation * Altering function signature of ConcatFuncErrors * Removing HasError method * Updating docs * Updates following code review * Adding changelog entries * Fix naming * Update website/docs/plugin/framework/functions/errors.mdx Co-authored-by: Austin Valle * Formatting * Updates following code review --------- Co-authored-by: Austin Valle * Update changelog * diag: remove incorrect code (#935) * update plugin-go dependency, fix errors and switch equal method * function: Add validation for parameter name conflicts and update defaulting logic (#936) * add validation and refactor defaulting logic * add changelogs * test fix * Update website/docs/plugin/framework/functions/documentation.mdx Co-authored-by: Brian Flad * refactor the logic, tests and docs for defaulting * Update website/docs/plugin/framework/functions/documentation.mdx Co-authored-by: Benjamin Bennett --------- Co-authored-by: Brian Flad Co-authored-by: Benjamin Bennett * resource/schema: Ensure invalid attribute default value errors are raised (#933) Reference: https://github.com/hashicorp/terraform-plugin-framework/issues/590 Reference: https://github.com/hashicorp/terraform-plugin-framework/issues/930 Previously the logic handling attribute `Default` values would silently ignore any type errors, which would lead to confusing planning data behaviors. This updates the logic to raise those error properly and adds covering unit testing. These error messages are using the underlying `tftypes` type system errors which is currently a pragmatic compromise throughout various parts of the framework logic that bridges between both type systems to save additional type assertion logic and potential bugs relating to those conversions. In the future if the internal `tftypes` handling and exported fields are replaced with the framework type system types, this logic would instead return error messaging based on the framework type system errors. This also will enhance the schema validation logic to check any `Default` response value and compare its type to the schema, which will raise framework type system errors during the `GetProviderSchema` RPC, or during schema unit testing if provider developers have implemented that additional testing. * Update provider functions testing docs to help users avoid nil pointer error (#940) * Fix capitalisation * Update example test to mitigate nil pointer error See https://github.com/hashicorp/terraform-plugin-framework/issues/928 * Update website/docs/plugin/framework/functions/testing.mdx Co-authored-by: Austin Valle * Update website/docs/plugin/framework/functions/testing.mdx Co-authored-by: Austin Valle * Update website/docs/plugin/framework/functions/testing.mdx Co-authored-by: Austin Valle * Update website/docs/plugin/framework/functions/testing.mdx Co-authored-by: Austin Valle * Update website/docs/plugin/framework/functions/testing.mdx Co-authored-by: Austin Valle --------- Co-authored-by: Austin Valle Co-authored-by: Brian Flad * Reinstate go toolchain and add changelog for Go version bump to 1.21 (#937) * Reinstate go toolchain and add changelog for Go version bump to 1.21 * Adding changelog * Updating changelog * Squashed commit of the following: commit e7415b7704c7a6b8af2f343462a746c6f532b121 Author: Benjamin Bennett Date: Fri Mar 1 16:16:39 2024 +0000 Reinstate go toolchain and add changelog for Go version bump to 1.21 (#937) * Reinstate go toolchain and add changelog for Go version bump to 1.21 * Adding changelog * Updating changelog commit 1597a9529ffbddfbfcb7e20bd42471f03557b3aa Author: Sarah French <15078782+SarahFrench@users.noreply.github.com> Date: Fri Mar 1 16:12:22 2024 +0000 Update provider functions testing docs to help users avoid nil pointer error (#940) * Fix capitalisation * Update example test to mitigate nil pointer error See https://github.com/hashicorp/terraform-plugin-framework/issues/928 * Update website/docs/plugin/framework/functions/testing.mdx Co-authored-by: Austin Valle * Update website/docs/plugin/framework/functions/testing.mdx Co-authored-by: Austin Valle * Update website/docs/plugin/framework/functions/testing.mdx Co-authored-by: Austin Valle * Update website/docs/plugin/framework/functions/testing.mdx Co-authored-by: Austin Valle * Update website/docs/plugin/framework/functions/testing.mdx Co-authored-by: Austin Valle --------- Co-authored-by: Austin Valle Co-authored-by: Brian Flad commit bd22b58c02491d36a2e237e8c2082d24f5eb5b43 Author: Brian Flad Date: Fri Mar 1 07:20:55 2024 -0500 resource/schema: Ensure invalid attribute default value errors are raised (#933) Reference: https://github.com/hashicorp/terraform-plugin-framework/issues/590 Reference: https://github.com/hashicorp/terraform-plugin-framework/issues/930 Previously the logic handling attribute `Default` values would silently ignore any type errors, which would lead to confusing planning data behaviors. This updates the logic to raise those error properly and adds covering unit testing. These error messages are using the underlying `tftypes` type system errors which is currently a pragmatic compromise throughout various parts of the framework logic that bridges between both type systems to save additional type assertion logic and potential bugs relating to those conversions. In the future if the internal `tftypes` handling and exported fields are replaced with the framework type system types, this logic would instead return error messaging based on the framework type system errors. This also will enhance the schema validation logic to check any `Default` response value and compare its type to the schema, which will raise framework type system errors during the `GetProviderSchema` RPC, or during schema unit testing if provider developers have implemented that additional testing. commit f03ca33c5b87df9080adc9b52cef1cf49115886c Author: Austin Valle Date: Thu Feb 29 14:23:29 2024 -0500 function: Add validation for parameter name conflicts and update defaulting logic (#936) * add validation and refactor defaulting logic * add changelogs * test fix * Update website/docs/plugin/framework/functions/documentation.mdx Co-authored-by: Brian Flad * refactor the logic, tests and docs for defaulting * Update website/docs/plugin/framework/functions/documentation.mdx Co-authored-by: Benjamin Bennett --------- Co-authored-by: Brian Flad Co-authored-by: Benjamin Bennett * fix dynamic param * incorrect merge conflict fix :) * update comments from feedback * refactor validation logic * license headers * implement remaining resource and datasource validation * implement provider schema validation * update error msg and add object validator * add validation to object attributes * update existing attributes to use new bool return * add validation to function parameters and definitions * refactor to only have one exported function * add tuple tests for completeness * create new fwtype package * add parameter name to validate implementation definition * various PR fixes * add more docs * fix docs from default param change * update comment * add more to reflection case + update comment * comment wording * remove todos and add package docs * add changelogs * Fix the use-case where dynamic value type is known, but the value itself is still null/unknown * check for dynamic underlying value for null/unknown detection * removed the unneccessary interface and removed half-implemented reflection support * doc formatting * update static default naming * add happy path tests for datasource + provider validate implementations * update definition validation messaging with variadic * add doc explicitly saying that dynamic types aren't supported * add docs mentioning required dynamic type to functions * prevent nil panics in `types.Dynamic` implementation w/ tests * proposal for `NewDynamicValue` * add recommendations in doc string * update block msg * update all doc strings and error messages to make recommendations * move the function validate interfaces to internal package * add maintainer note about parameter name * prevent attribute paths from stepping into dynamic types + new tests * add helper methods for checking underlying value unknown/null * add tests and comments about edge case with empty values * move maintainer note * Add dynamic type support documentation and considerations * first pass at dynamic documentation * Apply suggestions from code review Co-authored-by: Brian Flad * update literal representation in attribute page * drop callouts and link to collection type page * Update website/docs/plugin/framework/handling-data/attributes/dynamic.mdx Co-authored-by: Brian Flad * Apply suggestions from code review Co-authored-by: Brian Flad * Update website/docs/plugin/framework/functions/parameters/dynamic.mdx Co-authored-by: Brian Flad * Update website/docs/plugin/framework/handling-data/dynamic-data.mdx Co-authored-by: Brian Flad * include parameter name in example * adjust the path documentation to reflect new changes * rework the first paragraph of the data page * add callout for null underlying element values * add underlying value null/unknown information * Update website/docs/plugin/framework/handling-data/types/dynamic.mdx Co-authored-by: Brian Flad * Update website/docs/plugin/framework/handling-data/dynamic-data.mdx Co-authored-by: Brian Flad * add float64 and number notes --------- Co-authored-by: Brian Flad --------- Co-authored-by: Benjamin Bennett Co-authored-by: hc-github-team-tf-provider-devex Co-authored-by: John Behm Co-authored-by: Brian Flad Co-authored-by: Sarah French <15078782+SarahFrench@users.noreply.github.com> --- .../unreleased/FEATURES-20240311-175905.yaml | 6 + .../unreleased/FEATURES-20240311-180136.yaml | 7 + .../unreleased/FEATURES-20240311-180351.yaml | 6 + .../unreleased/FEATURES-20240311-180418.yaml | 6 + .../unreleased/FEATURES-20240311-180430.yaml | 6 + .../unreleased/FEATURES-20240311-180515.yaml | 5 + .../unreleased/FEATURES-20240311-180859.yaml | 6 + .../unreleased/FEATURES-20240311-181044.yaml | 6 + .../unreleased/FEATURES-20240311-181151.yaml | 6 + .../unreleased/FEATURES-20240311-181242.yaml | 6 + .../unreleased/FEATURES-20240311-181424.yaml | 5 + attr/value.go | 6 + datasource/schema/dynamic_attribute.go | 188 ++++ datasource/schema/dynamic_attribute_test.go | 425 ++++++++ datasource/schema/list_attribute.go | 9 + datasource/schema/list_attribute_test.go | 22 + datasource/schema/list_nested_attribute.go | 21 +- .../schema/list_nested_attribute_test.go | 84 ++ datasource/schema/list_nested_block.go | 21 +- datasource/schema/list_nested_block_test.go | 83 ++ datasource/schema/map_attribute.go | 9 + datasource/schema/map_attribute_test.go | 22 + datasource/schema/map_nested_attribute.go | 21 +- .../schema/map_nested_attribute_test.go | 84 ++ datasource/schema/object_attribute.go | 9 + datasource/schema/object_attribute_test.go | 47 + datasource/schema/set_attribute.go | 9 + datasource/schema/set_attribute_test.go | 22 + datasource/schema/set_nested_attribute.go | 21 +- .../schema/set_nested_attribute_test.go | 84 ++ datasource/schema/set_nested_block.go | 21 +- datasource/schema/set_nested_block_test.go | 83 ++ function/arguments_data_test.go | 58 ++ function/definition.go | 32 + function/definition_test.go | 60 ++ function/dynamic_parameter.go | 102 ++ function/dynamic_parameter_test.go | 242 +++++ function/dynamic_return.go | 53 + function/dynamic_return_test.go | 48 + function/list_parameter.go | 31 +- function/list_parameter_test.go | 91 ++ function/list_return.go | 21 +- function/list_return_test.go | 62 ++ function/map_parameter.go | 31 +- function/map_parameter_test.go | 91 ++ function/map_return.go | 21 +- function/map_return_test.go | 62 ++ function/object_parameter.go | 31 +- function/object_parameter_test.go | 120 +++ function/object_return.go | 21 +- function/object_return_test.go | 85 ++ function/set_parameter.go | 31 +- function/set_parameter_test.go | 91 ++ function/set_return.go | 21 +- function/set_return_test.go | 62 ++ internal/fwfunction/doc.go | 6 + .../parameter_validate_implementation.go | 54 + .../return_validate_implementation.go | 43 + internal/fwschema/attribute_default.go | 8 + internal/fwschema/errors.go | 8 +- .../fwxschema/attribute_plan_modification.go | 9 + .../fwxschema/attribute_validation.go | 9 + internal/fwschema/schema.go | 34 +- internal/fwschema/schema_test.go | 360 +++++++ internal/fwschemadata/data_default.go | 77 +- internal/fwschemadata/data_default_test.go | 555 ++++++++++ .../fwschemadata/data_get_at_path_test.go | 296 ++++++ internal/fwschemadata/data_get_test.go | 338 +++++++ .../data_nullify_collection_blocks.go | 15 + .../data_nullify_collection_blocks_test.go | 59 ++ .../fwschemadata/data_path_exists_test.go | 366 +++++++ .../fwschemadata/data_path_matches_test.go | 93 ++ .../data_reify_null_collection_blocks.go | 15 + .../data_reify_null_collection_blocks_test.go | 59 ++ internal/fwschemadata/data_set_at_path.go | 4 - .../fwschemadata/data_set_at_path_test.go | 69 ++ internal/fwschemadata/data_set_test.go | 104 ++ .../data_valid_path_expression_test.go | 29 + internal/fwschemadata/data_value_test.go | 312 ++++++ .../fwschemadata/value_semantic_equality.go | 2 + .../value_semantic_equality_dynamic.go | 78 ++ .../value_semantic_equality_dynamic_test.go | 203 ++++ internal/fwserver/attr_type.go | 15 + .../fwserver/attribute_plan_modification.go | 162 +++ .../attribute_plan_modification_test.go | 636 ++++++++++++ internal/fwserver/attribute_validation.go | 98 +- .../fwserver/attribute_validation_test.go | 283 ++++++ .../fwserver/server_planresourcechange.go | 50 +- .../server_planresourcechange_test.go | 108 +- internal/fwtype/doc.go | 5 + .../fwtype/static_collection_validation.go | 142 +++ .../static_collection_validation_test.go | 947 ++++++++++++++++++ internal/reflect/into.go | 14 + internal/reflect/into_test.go | 60 ++ internal/reflect/outof_test.go | 37 + internal/reflect/struct.go | 11 +- internal/testing/testdefaults/dynamic.go | 49 + internal/testing/testplanmodifier/dynamic.go | 47 + .../testschema/attributewithdynamicdefault.go | 87 ++ .../attributewithdynamicplanmodifiers.go | 87 ++ .../attributewithdynamicvalidators.go | 87 ++ internal/testing/testtypes/dynamic.go | 42 + .../testtypes/dynamicwithsemanticequals.go | 116 +++ internal/testing/testvalidator/dynamic.go | 47 + internal/toproto5/function_test.go | 9 + internal/toproto5/getproviderschema_test.go | 89 +- internal/toproto6/function_test.go | 9 + internal/toproto6/getproviderschema_test.go | 89 +- provider/schema/dynamic_attribute.go | 177 ++++ provider/schema/dynamic_attribute_test.go | 419 ++++++++ provider/schema/list_attribute.go | 9 + provider/schema/list_attribute_test.go | 22 + provider/schema/list_nested_attribute.go | 21 +- provider/schema/list_nested_attribute_test.go | 84 ++ provider/schema/list_nested_block.go | 21 +- provider/schema/list_nested_block_test.go | 83 ++ provider/schema/map_attribute.go | 9 + provider/schema/map_attribute_test.go | 22 + provider/schema/map_nested_attribute.go | 16 + provider/schema/map_nested_attribute_test.go | 84 ++ provider/schema/object_attribute.go | 9 + provider/schema/object_attribute_test.go | 47 + provider/schema/set_attribute.go | 9 + provider/schema/set_attribute_test.go | 22 + provider/schema/set_nested_attribute.go | 21 +- provider/schema/set_nested_attribute_test.go | 84 ++ provider/schema/set_nested_block.go | 21 +- provider/schema/set_nested_block_test.go | 83 ++ resource/schema/defaults/dynamic.go | 36 + resource/schema/dynamic_attribute.go | 241 +++++ resource/schema/dynamic_attribute_test.go | 578 +++++++++++ resource/schema/dynamicdefault/doc.go | 5 + .../schema/dynamicdefault/static_value.go | 42 + .../dynamicdefault/static_value_test.go | 47 + resource/schema/dynamicplanmodifier/doc.go | 5 + .../dynamicplanmodifier/requires_replace.go | 30 + .../requires_replace_if.go | 73 ++ .../requires_replace_if_configured.go | 35 + .../requires_replace_if_configured_test.go | 183 ++++ .../requires_replace_if_func.go | 25 + .../requires_replace_if_test.go | 181 ++++ .../requires_replace_test.go | 153 +++ .../use_state_for_unknown.go | 58 ++ .../use_state_for_unknown_test.go | 159 +++ resource/schema/list_attribute.go | 9 + resource/schema/list_attribute_test.go | 22 + resource/schema/list_nested_attribute.go | 9 + resource/schema/list_nested_attribute_test.go | 27 + resource/schema/list_nested_block.go | 23 +- resource/schema/list_nested_block_test.go | 56 ++ resource/schema/map_attribute.go | 9 + resource/schema/map_attribute_test.go | 22 + resource/schema/map_nested_attribute.go | 9 + resource/schema/map_nested_attribute_test.go | 27 + resource/schema/object_attribute.go | 9 + resource/schema/object_attribute_test.go | 47 + resource/schema/planmodifier/dynamic.go | 88 ++ resource/schema/set_attribute.go | 9 + resource/schema/set_attribute_test.go | 22 + resource/schema/set_nested_attribute.go | 9 + resource/schema/set_nested_attribute_test.go | 27 + resource/schema/set_nested_block.go | 23 +- resource/schema/set_nested_block_test.go | 56 ++ schema/validator/dynamic.go | 46 + types/basetypes/dynamic_type.go | 198 ++++ types/basetypes/dynamic_type_test.go | 443 ++++++++ types/basetypes/dynamic_value.go | 197 ++++ types/basetypes/dynamic_value_test.go | 790 +++++++++++++++ types/basetypes/list_type.go | 14 + types/basetypes/list_value.go | 13 + types/basetypes/list_value_test.go | 38 + types/basetypes/map_type.go | 14 + types/basetypes/map_value.go | 13 + types/basetypes/map_value_test.go | 38 + types/basetypes/object_type_test.go | 32 + types/basetypes/object_value_test.go | 58 ++ types/basetypes/set_type.go | 14 + types/basetypes/set_value.go | 13 + types/basetypes/set_value_test.go | 38 + types/basetypes/tuple_type_test.go | 50 +- types/basetypes/tuple_value_test.go | 32 +- types/dynamic_type.go | 8 + types/dynamic_value.go | 29 + website/data/plugin-framework-nav-data.json | 20 + .../functions/parameters/dynamic.mdx | 176 ++++ .../framework/functions/parameters/index.mdx | 21 +- .../framework/functions/returns/bool.mdx | 2 +- .../framework/functions/returns/dynamic.mdx | 77 ++ .../framework/functions/returns/float64.mdx | 2 +- .../framework/functions/returns/index.mdx | 13 +- .../framework/functions/returns/int64.mdx | 2 +- .../framework/functions/returns/list.mdx | 2 +- .../framework/functions/returns/map.mdx | 2 +- .../framework/functions/returns/number.mdx | 2 +- .../framework/functions/returns/object.mdx | 2 +- .../framework/functions/returns/set.mdx | 2 +- .../framework/functions/returns/string.mdx | 2 +- .../handling-data/attributes/dynamic.mdx | 146 +++ .../handling-data/attributes/index.mdx | 25 +- .../framework/handling-data/dynamic-data.mdx | 222 ++++ .../plugin/framework/handling-data/paths.mdx | 45 +- .../framework/handling-data/types/bool.mdx | 4 +- .../framework/handling-data/types/custom.mdx | 2 + .../framework/handling-data/types/dynamic.mdx | 154 +++ .../framework/handling-data/types/index.mdx | 12 + .../framework/handling-data/types/tuple.mdx | 2 + .../plugin/framework/resources/default.mdx | 1 + 207 files changed, 15841 insertions(+), 146 deletions(-) create mode 100644 .changes/unreleased/FEATURES-20240311-175905.yaml create mode 100644 .changes/unreleased/FEATURES-20240311-180136.yaml create mode 100644 .changes/unreleased/FEATURES-20240311-180351.yaml create mode 100644 .changes/unreleased/FEATURES-20240311-180418.yaml create mode 100644 .changes/unreleased/FEATURES-20240311-180430.yaml create mode 100644 .changes/unreleased/FEATURES-20240311-180515.yaml create mode 100644 .changes/unreleased/FEATURES-20240311-180859.yaml create mode 100644 .changes/unreleased/FEATURES-20240311-181044.yaml create mode 100644 .changes/unreleased/FEATURES-20240311-181151.yaml create mode 100644 .changes/unreleased/FEATURES-20240311-181242.yaml create mode 100644 .changes/unreleased/FEATURES-20240311-181424.yaml create mode 100644 datasource/schema/dynamic_attribute.go create mode 100644 datasource/schema/dynamic_attribute_test.go create mode 100644 function/dynamic_parameter.go create mode 100644 function/dynamic_parameter_test.go create mode 100644 function/dynamic_return.go create mode 100644 function/dynamic_return_test.go create mode 100644 internal/fwfunction/doc.go create mode 100644 internal/fwfunction/parameter_validate_implementation.go create mode 100644 internal/fwfunction/return_validate_implementation.go create mode 100644 internal/fwschemadata/value_semantic_equality_dynamic.go create mode 100644 internal/fwschemadata/value_semantic_equality_dynamic_test.go create mode 100644 internal/fwtype/doc.go create mode 100644 internal/fwtype/static_collection_validation.go create mode 100644 internal/fwtype/static_collection_validation_test.go create mode 100644 internal/testing/testdefaults/dynamic.go create mode 100644 internal/testing/testplanmodifier/dynamic.go create mode 100644 internal/testing/testschema/attributewithdynamicdefault.go create mode 100644 internal/testing/testschema/attributewithdynamicplanmodifiers.go create mode 100644 internal/testing/testschema/attributewithdynamicvalidators.go create mode 100644 internal/testing/testtypes/dynamic.go create mode 100644 internal/testing/testtypes/dynamicwithsemanticequals.go create mode 100644 internal/testing/testvalidator/dynamic.go create mode 100644 provider/schema/dynamic_attribute.go create mode 100644 provider/schema/dynamic_attribute_test.go create mode 100644 resource/schema/defaults/dynamic.go create mode 100644 resource/schema/dynamic_attribute.go create mode 100644 resource/schema/dynamic_attribute_test.go create mode 100644 resource/schema/dynamicdefault/doc.go create mode 100644 resource/schema/dynamicdefault/static_value.go create mode 100644 resource/schema/dynamicdefault/static_value_test.go create mode 100644 resource/schema/dynamicplanmodifier/doc.go create mode 100644 resource/schema/dynamicplanmodifier/requires_replace.go create mode 100644 resource/schema/dynamicplanmodifier/requires_replace_if.go create mode 100644 resource/schema/dynamicplanmodifier/requires_replace_if_configured.go create mode 100644 resource/schema/dynamicplanmodifier/requires_replace_if_configured_test.go create mode 100644 resource/schema/dynamicplanmodifier/requires_replace_if_func.go create mode 100644 resource/schema/dynamicplanmodifier/requires_replace_if_test.go create mode 100644 resource/schema/dynamicplanmodifier/requires_replace_test.go create mode 100644 resource/schema/dynamicplanmodifier/use_state_for_unknown.go create mode 100644 resource/schema/dynamicplanmodifier/use_state_for_unknown_test.go create mode 100644 resource/schema/planmodifier/dynamic.go create mode 100644 schema/validator/dynamic.go create mode 100644 types/basetypes/dynamic_type.go create mode 100644 types/basetypes/dynamic_type_test.go create mode 100644 types/basetypes/dynamic_value.go create mode 100644 types/basetypes/dynamic_value_test.go create mode 100644 types/dynamic_type.go create mode 100644 types/dynamic_value.go create mode 100644 website/docs/plugin/framework/functions/parameters/dynamic.mdx create mode 100644 website/docs/plugin/framework/functions/returns/dynamic.mdx create mode 100644 website/docs/plugin/framework/handling-data/attributes/dynamic.mdx create mode 100644 website/docs/plugin/framework/handling-data/dynamic-data.mdx create mode 100644 website/docs/plugin/framework/handling-data/types/dynamic.mdx diff --git a/.changes/unreleased/FEATURES-20240311-175905.yaml b/.changes/unreleased/FEATURES-20240311-175905.yaml new file mode 100644 index 000000000..e2fdb3ae6 --- /dev/null +++ b/.changes/unreleased/FEATURES-20240311-175905.yaml @@ -0,0 +1,6 @@ +kind: FEATURES +body: 'types/basetypes: Added `DynamicType` and `DynamicValue` implementations for + dynamic value handling' +time: 2024-03-11T17:59:05.67474-04:00 +custom: + Issue: "147" diff --git a/.changes/unreleased/FEATURES-20240311-180136.yaml b/.changes/unreleased/FEATURES-20240311-180136.yaml new file mode 100644 index 000000000..26e8f3cfa --- /dev/null +++ b/.changes/unreleased/FEATURES-20240311-180136.yaml @@ -0,0 +1,7 @@ +kind: FEATURES +body: 'types/basetypes: Added interfaces `basetypes.DynamicTypable`, `basetypes.DynamicValuable`, + and `basetypes.DynamicValuableWithSemanticEquals` for dynamic custom type and value + implementations' +time: 2024-03-11T18:01:36.888566-04:00 +custom: + Issue: "147" diff --git a/.changes/unreleased/FEATURES-20240311-180351.yaml b/.changes/unreleased/FEATURES-20240311-180351.yaml new file mode 100644 index 000000000..2c1313e4f --- /dev/null +++ b/.changes/unreleased/FEATURES-20240311-180351.yaml @@ -0,0 +1,6 @@ +kind: FEATURES +body: 'resource/schema: Added `DynamicAttribute` implementation for dynamic value + handling' +time: 2024-03-11T18:03:51.559347-04:00 +custom: + Issue: "147" diff --git a/.changes/unreleased/FEATURES-20240311-180418.yaml b/.changes/unreleased/FEATURES-20240311-180418.yaml new file mode 100644 index 000000000..3ce4ffd6f --- /dev/null +++ b/.changes/unreleased/FEATURES-20240311-180418.yaml @@ -0,0 +1,6 @@ +kind: FEATURES +body: 'datasource/schema: Added `DynamicAttribute` implementation for dynamic value + handling' +time: 2024-03-11T18:04:18.042171-04:00 +custom: + Issue: "147" diff --git a/.changes/unreleased/FEATURES-20240311-180430.yaml b/.changes/unreleased/FEATURES-20240311-180430.yaml new file mode 100644 index 000000000..51077efba --- /dev/null +++ b/.changes/unreleased/FEATURES-20240311-180430.yaml @@ -0,0 +1,6 @@ +kind: FEATURES +body: 'provider/schema: Added `DynamicAttribute` implementation for dynamic value + handling' +time: 2024-03-11T18:04:30.200616-04:00 +custom: + Issue: "147" diff --git a/.changes/unreleased/FEATURES-20240311-180515.yaml b/.changes/unreleased/FEATURES-20240311-180515.yaml new file mode 100644 index 000000000..2995a73eb --- /dev/null +++ b/.changes/unreleased/FEATURES-20240311-180515.yaml @@ -0,0 +1,5 @@ +kind: FEATURES +body: 'function: Added `DynamicParameter` and `DynamicReturn` for dynamic value handling`' +time: 2024-03-11T18:05:15.196275-04:00 +custom: + Issue: "147" diff --git a/.changes/unreleased/FEATURES-20240311-180859.yaml b/.changes/unreleased/FEATURES-20240311-180859.yaml new file mode 100644 index 000000000..e341c2ebf --- /dev/null +++ b/.changes/unreleased/FEATURES-20240311-180859.yaml @@ -0,0 +1,6 @@ +kind: FEATURES +body: 'resource/schema/dynamicdefault: New package with `StaticValue` implementation + for dynamic schema-based default values' +time: 2024-03-11T18:08:59.479664-04:00 +custom: + Issue: "147" diff --git a/.changes/unreleased/FEATURES-20240311-181044.yaml b/.changes/unreleased/FEATURES-20240311-181044.yaml new file mode 100644 index 000000000..dab99c2cf --- /dev/null +++ b/.changes/unreleased/FEATURES-20240311-181044.yaml @@ -0,0 +1,6 @@ +kind: FEATURES +body: 'resource/schema/dynamicplanmodifier: New package with built-in implementations + for dynamic value plan modification.' +time: 2024-03-11T18:10:44.015502-04:00 +custom: + Issue: "147" diff --git a/.changes/unreleased/FEATURES-20240311-181151.yaml b/.changes/unreleased/FEATURES-20240311-181151.yaml new file mode 100644 index 000000000..edef19f5d --- /dev/null +++ b/.changes/unreleased/FEATURES-20240311-181151.yaml @@ -0,0 +1,6 @@ +kind: FEATURES +body: 'resource/schema/defaults: New `Dynamic` interface for dynamic schema-based + default implementations' +time: 2024-03-11T18:11:51.403326-04:00 +custom: + Issue: "147" diff --git a/.changes/unreleased/FEATURES-20240311-181242.yaml b/.changes/unreleased/FEATURES-20240311-181242.yaml new file mode 100644 index 000000000..110103c4a --- /dev/null +++ b/.changes/unreleased/FEATURES-20240311-181242.yaml @@ -0,0 +1,6 @@ +kind: FEATURES +body: 'resource/schema/planmodifier: New `Dynamic` interface for dynamic value plan + modification implementations' +time: 2024-03-11T18:12:42.945376-04:00 +custom: + Issue: "147" diff --git a/.changes/unreleased/FEATURES-20240311-181424.yaml b/.changes/unreleased/FEATURES-20240311-181424.yaml new file mode 100644 index 000000000..7426b947c --- /dev/null +++ b/.changes/unreleased/FEATURES-20240311-181424.yaml @@ -0,0 +1,5 @@ +kind: FEATURES +body: 'schema/validator: New `Dynamic` interface for dynamic value schema validation' +time: 2024-03-11T18:14:24.809064-04:00 +custom: + Issue: "147" diff --git a/attr/value.go b/attr/value.go index 370f0ddc9..b34a3bb73 100644 --- a/attr/value.go +++ b/attr/value.go @@ -17,6 +17,12 @@ const ( // NullValueString should be returned by Value.String() implementations // when Value.IsNull() returns true. NullValueString = "" + + // UnsetValueString should be returned by Value.String() implementations + // when Value does not contain sufficient information to display to users. + // + // This is primarily used for invalid Dynamic Value implementations. + UnsetValueString = "" ) // Value defines an interface for describing data associated with an attribute. diff --git a/datasource/schema/dynamic_attribute.go b/datasource/schema/dynamic_attribute.go new file mode 100644 index 000000000..6b1b6c83e --- /dev/null +++ b/datasource/schema/dynamic_attribute.go @@ -0,0 +1,188 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package schema + +import ( + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschema/fwxschema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +// Ensure the implementation satisifies the desired interfaces. +var ( + _ Attribute = DynamicAttribute{} + _ fwxschema.AttributeWithDynamicValidators = DynamicAttribute{} +) + +// DynamicAttribute represents a schema attribute that is a dynamic, rather +// than a single static type. Static types are always preferable over dynamic +// types in Terraform as practitioners will receive less helpful configuration +// assistance from validation error diagnostics and editor integrations. When +// retrieving the value for this attribute, use types.Dynamic as the value type +// unless the CustomType field is set. +// +// The concrete value type for a dynamic is determined at runtime in this order: +// 1. By Terraform, if defined in the configuration (if Required or Optional). +// 2. By the provider (if Computed). +// +// Once the concrete value type has been determined, it must remain consistent between +// plan and apply or Terraform will return an error. +type DynamicAttribute struct { + // CustomType enables the use of a custom attribute type in place of the + // default basetypes.DynamicType. When retrieving data, the basetypes.DynamicValuable + // associated with this custom type must be used in place of types.Dynamic. + CustomType basetypes.DynamicTypable + + // Required indicates whether the practitioner must enter a value for + // this attribute or not. Required and Optional cannot both be true, + // and Required and Computed cannot both be true. + Required bool + + // Optional indicates whether the practitioner can choose to enter a value + // for this attribute or not. Optional and Required cannot both be true. + Optional bool + + // Computed indicates whether the provider may return its own value for + // this Attribute or not. Required and Computed cannot both be true. If + // Required and Optional are both false, Computed must be true, and the + // attribute will be considered "read only" for the practitioner, with + // only the provider able to set its value. + Computed bool + + // Sensitive indicates whether the value of this attribute should be + // considered sensitive data. Setting it to true will obscure the value + // in CLI output. Sensitive does not impact how values are stored, and + // practitioners are encouraged to store their state as if the entire + // file is sensitive. + Sensitive bool + + // Description is used in various tooling, like the language server, to + // give practitioners more information about what this attribute is, + // what it's for, and how it should be used. It should be written as + // plain text, with no special formatting. + Description string + + // MarkdownDescription is used in various tooling, like the + // documentation generator, to give practitioners more information + // about what this attribute is, what it's for, and how it should be + // used. It should be formatted using Markdown. + MarkdownDescription string + + // DeprecationMessage defines warning diagnostic details to display when + // practitioner configurations use this Attribute. The warning diagnostic + // summary is automatically set to "Attribute Deprecated" along with + // configuration source file and line information. + // + // Set this field to a practitioner actionable message such as: + // + // - "Configure other_attribute instead. This attribute will be removed + // in the next major version of the provider." + // - "Remove this attribute's configuration as it no longer is used and + // the attribute will be removed in the next major version of the + // provider." + // + // In Terraform 1.2.7 and later, this warning diagnostic is displayed any + // time a practitioner attempts to configure a value for this attribute and + // certain scenarios where this attribute is referenced. + // + // In Terraform 1.2.6 and earlier, this warning diagnostic is only + // displayed when the Attribute is Required or Optional, and if the + // practitioner configuration sets the value to a known or unknown value + // (which may eventually be null). It has no effect when the Attribute is + // Computed-only (read-only; not Required or Optional). + // + // Across any Terraform version, there are no warnings raised for + // practitioner configuration values set directly to null, as there is no + // way for the framework to differentiate between an unset and null + // configuration due to how Terraform sends configuration information + // across the protocol. + // + // Additional information about deprecation enhancements for read-only + // attributes can be found in: + // + // - https://github.com/hashicorp/terraform/issues/7569 + // + DeprecationMessage string + + // Validators define value validation functionality for the attribute. All + // elements of the slice of AttributeValidator are run, regardless of any + // previous error diagnostics. + // + // Many common use case validators can be found in the + // github.com/hashicorp/terraform-plugin-framework-validators Go module. + // + // If the Type field points to a custom type that implements the + // xattr.TypeWithValidate interface, the validators defined in this field + // are run in addition to the validation defined by the type. + Validators []validator.Dynamic +} + +// ApplyTerraform5AttributePathStep always returns an error as it is not +// possible to step further into a DynamicAttribute. +func (a DynamicAttribute) ApplyTerraform5AttributePathStep(step tftypes.AttributePathStep) (interface{}, error) { + return a.GetType().ApplyTerraform5AttributePathStep(step) +} + +// Equal returns true if the given Attribute is a DynamicAttribute +// and all fields are equal. +func (a DynamicAttribute) Equal(o fwschema.Attribute) bool { + if _, ok := o.(DynamicAttribute); !ok { + return false + } + + return fwschema.AttributesEqual(a, o) +} + +// GetDeprecationMessage returns the DeprecationMessage field value. +func (a DynamicAttribute) GetDeprecationMessage() string { + return a.DeprecationMessage +} + +// GetDescription returns the Description field value. +func (a DynamicAttribute) GetDescription() string { + return a.Description +} + +// GetMarkdownDescription returns the MarkdownDescription field value. +func (a DynamicAttribute) GetMarkdownDescription() string { + return a.MarkdownDescription +} + +// GetType returns types.DynamicType or the CustomType field value if defined. +func (a DynamicAttribute) GetType() attr.Type { + if a.CustomType != nil { + return a.CustomType + } + + return types.DynamicType +} + +// IsComputed returns the Computed field value. +func (a DynamicAttribute) IsComputed() bool { + return a.Computed +} + +// IsOptional returns the Optional field value. +func (a DynamicAttribute) IsOptional() bool { + return a.Optional +} + +// IsRequired returns the Required field value. +func (a DynamicAttribute) IsRequired() bool { + return a.Required +} + +// IsSensitive returns the Sensitive field value. +func (a DynamicAttribute) IsSensitive() bool { + return a.Sensitive +} + +// DynamicValidators returns the Validators field value. +func (a DynamicAttribute) DynamicValidators() []validator.Dynamic { + return a.Validators +} diff --git a/datasource/schema/dynamic_attribute_test.go b/datasource/schema/dynamic_attribute_test.go new file mode 100644 index 000000000..8981dbecd --- /dev/null +++ b/datasource/schema/dynamic_attribute_test.go @@ -0,0 +1,425 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package schema_test + +import ( + "fmt" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" + "github.com/hashicorp/terraform-plugin-framework/internal/testing/testschema" + "github.com/hashicorp/terraform-plugin-framework/internal/testing/testtypes" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestDynamicAttributeApplyTerraform5AttributePathStep(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attribute schema.DynamicAttribute + step tftypes.AttributePathStep + expected any + expectedError error + }{ + "AttributeName": { + attribute: schema.DynamicAttribute{}, + step: tftypes.AttributeName("test"), + expected: nil, + expectedError: fmt.Errorf("cannot apply AttributePathStep tftypes.AttributeName to basetypes.DynamicType"), + }, + "ElementKeyInt": { + attribute: schema.DynamicAttribute{}, + step: tftypes.ElementKeyInt(1), + expected: nil, + expectedError: fmt.Errorf("cannot apply AttributePathStep tftypes.ElementKeyInt to basetypes.DynamicType"), + }, + "ElementKeyString": { + attribute: schema.DynamicAttribute{}, + step: tftypes.ElementKeyString("test"), + expected: nil, + expectedError: fmt.Errorf("cannot apply AttributePathStep tftypes.ElementKeyString to basetypes.DynamicType"), + }, + "ElementKeyValue": { + attribute: schema.DynamicAttribute{}, + step: tftypes.ElementKeyValue(tftypes.NewValue(tftypes.String, "test")), + expected: nil, + expectedError: fmt.Errorf("cannot apply AttributePathStep tftypes.ElementKeyValue to basetypes.DynamicType"), + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, err := testCase.attribute.ApplyTerraform5AttributePathStep(testCase.step) + + if err != nil { + if testCase.expectedError == nil { + t.Fatalf("expected no error, got: %s", err) + } + + if !strings.Contains(err.Error(), testCase.expectedError.Error()) { + t.Fatalf("expected error %q, got: %s", testCase.expectedError, err) + } + } + + if err == nil && testCase.expectedError != nil { + t.Fatalf("got no error, expected: %s", testCase.expectedError) + } + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestDynamicAttributeGetDeprecationMessage(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attribute schema.DynamicAttribute + expected string + }{ + "no-deprecation-message": { + attribute: schema.DynamicAttribute{}, + expected: "", + }, + "deprecation-message": { + attribute: schema.DynamicAttribute{ + DeprecationMessage: "test deprecation message", + }, + expected: "test deprecation message", + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.attribute.GetDeprecationMessage() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestDynamicAttributeEqual(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attribute schema.DynamicAttribute + other fwschema.Attribute + expected bool + }{ + "different-type": { + attribute: schema.DynamicAttribute{}, + other: testschema.AttributeWithDynamicValidators{}, + expected: false, + }, + "equal": { + attribute: schema.DynamicAttribute{}, + other: schema.DynamicAttribute{}, + expected: true, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.attribute.Equal(testCase.other) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestDynamicAttributeGetDescription(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attribute schema.DynamicAttribute + expected string + }{ + "no-description": { + attribute: schema.DynamicAttribute{}, + expected: "", + }, + "description": { + attribute: schema.DynamicAttribute{ + Description: "test description", + }, + expected: "test description", + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.attribute.GetDescription() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestDynamicAttributeGetMarkdownDescription(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attribute schema.DynamicAttribute + expected string + }{ + "no-markdown-description": { + attribute: schema.DynamicAttribute{}, + expected: "", + }, + "markdown-description": { + attribute: schema.DynamicAttribute{ + MarkdownDescription: "test description", + }, + expected: "test description", + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.attribute.GetMarkdownDescription() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestDynamicAttributeGetType(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attribute schema.DynamicAttribute + expected attr.Type + }{ + "base": { + attribute: schema.DynamicAttribute{}, + expected: types.DynamicType, + }, + "custom-type": { + attribute: schema.DynamicAttribute{ + CustomType: testtypes.DynamicType{}, + }, + expected: testtypes.DynamicType{}, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.attribute.GetType() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestDynamicAttributeIsComputed(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attribute schema.DynamicAttribute + expected bool + }{ + "not-computed": { + attribute: schema.DynamicAttribute{}, + expected: false, + }, + "computed": { + attribute: schema.DynamicAttribute{ + Computed: true, + }, + expected: true, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.attribute.IsComputed() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestDynamicAttributeIsOptional(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attribute schema.DynamicAttribute + expected bool + }{ + "not-optional": { + attribute: schema.DynamicAttribute{}, + expected: false, + }, + "optional": { + attribute: schema.DynamicAttribute{ + Optional: true, + }, + expected: true, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.attribute.IsOptional() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestDynamicAttributeIsRequired(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attribute schema.DynamicAttribute + expected bool + }{ + "not-required": { + attribute: schema.DynamicAttribute{}, + expected: false, + }, + "required": { + attribute: schema.DynamicAttribute{ + Required: true, + }, + expected: true, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.attribute.IsRequired() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestDynamicAttributeIsSensitive(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attribute schema.DynamicAttribute + expected bool + }{ + "not-sensitive": { + attribute: schema.DynamicAttribute{}, + expected: false, + }, + "sensitive": { + attribute: schema.DynamicAttribute{ + Sensitive: true, + }, + expected: true, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.attribute.IsSensitive() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestDynamicAttributeDynamicValidators(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attribute schema.DynamicAttribute + expected []validator.Dynamic + }{ + "no-validators": { + attribute: schema.DynamicAttribute{}, + expected: nil, + }, + "validators": { + attribute: schema.DynamicAttribute{ + Validators: []validator.Dynamic{}, + }, + expected: []validator.Dynamic{}, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.attribute.DynamicValidators() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/datasource/schema/list_attribute.go b/datasource/schema/list_attribute.go index e214cb440..9d502067f 100644 --- a/datasource/schema/list_attribute.go +++ b/datasource/schema/list_attribute.go @@ -9,6 +9,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema/fwxschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwtype" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" @@ -44,6 +45,10 @@ var ( type ListAttribute struct { // ElementType is the type for all elements of the list. This field must be // set. + // + // Element types that contain a dynamic type (i.e. types.Dynamic) are not supported. + // If underlying dynamic values are required, replace this attribute definition with + // DynamicAttribute instead. ElementType attr.Type // CustomType enables the use of a custom attribute type in place of the @@ -210,4 +215,8 @@ func (a ListAttribute) ValidateImplementation(ctx context.Context, req fwschema. if a.CustomType == nil && a.ElementType == nil { resp.Diagnostics.Append(fwschema.AttributeMissingElementTypeDiag(req.Path)) } + + if a.CustomType == nil && fwtype.ContainsCollectionWithDynamic(a.GetType()) { + resp.Diagnostics.Append(fwtype.AttributeCollectionWithDynamicTypeDiag(req.Path)) + } } diff --git a/datasource/schema/list_attribute_test.go b/datasource/schema/list_attribute_test.go index bfa3a029e..5c33f4fbe 100644 --- a/datasource/schema/list_attribute_test.go +++ b/datasource/schema/list_attribute_test.go @@ -462,6 +462,28 @@ func TestListAttributeValidateImplementation(t *testing.T) { }, expected: &fwschema.ValidateImplementationResponse{}, }, + "elementtype-dynamic": { + attribute: schema.ListAttribute{ + Computed: true, + ElementType: types.DynamicType, + }, + request: fwschema.ValidateImplementationRequest{ + Name: "test", + Path: path.Root("test"), + }, + expected: &fwschema.ValidateImplementationResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Schema Implementation", + "When validating the schema, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "\"test\" is an attribute that contains a collection type with a nested dynamic type.\n\n"+ + "Dynamic types inside of collections are not currently supported in terraform-plugin-framework. "+ + "If underlying dynamic values are required, replace the \"test\" attribute definition with DynamicAttribute instead.", + ), + }, + }, + }, "elementtype-missing": { attribute: schema.ListAttribute{ Computed: true, diff --git a/datasource/schema/list_nested_attribute.go b/datasource/schema/list_nested_attribute.go index d6d2cef67..b9b70d6fb 100644 --- a/datasource/schema/list_nested_attribute.go +++ b/datasource/schema/list_nested_attribute.go @@ -4,11 +4,13 @@ package schema import ( + "context" "fmt" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema/fwxschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwtype" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" @@ -17,8 +19,9 @@ import ( // Ensure the implementation satisifies the desired interfaces. var ( - _ NestedAttribute = ListNestedAttribute{} - _ fwxschema.AttributeWithListValidators = ListNestedAttribute{} + _ NestedAttribute = ListNestedAttribute{} + _ fwschema.AttributeWithValidateImplementation = ListNestedAttribute{} + _ fwxschema.AttributeWithListValidators = ListNestedAttribute{} ) // ListNestedAttribute represents an attribute that is a list of objects where @@ -51,6 +54,10 @@ var ( type ListNestedAttribute struct { // NestedObject is the underlying object that contains nested attributes. // This field must be set. + // + // Nested attributes that contain a dynamic type (i.e. DynamicAttribute) are not supported. + // If underlying dynamic values are required, replace this attribute definition with + // DynamicAttribute instead. NestedObject NestedAttributeObject // CustomType enables the use of a custom attribute type in place of the @@ -227,3 +234,13 @@ func (a ListNestedAttribute) IsSensitive() bool { func (a ListNestedAttribute) ListValidators() []validator.List { return a.Validators } + +// ValidateImplementation contains logic for validating the +// provider-defined implementation of the attribute to prevent unexpected +// errors or panics. This logic runs during the GetProviderSchema RPC and +// should never include false positives. +func (a ListNestedAttribute) ValidateImplementation(ctx context.Context, req fwschema.ValidateImplementationRequest, resp *fwschema.ValidateImplementationResponse) { + if a.CustomType == nil && fwtype.ContainsCollectionWithDynamic(a.GetType()) { + resp.Diagnostics.Append(fwtype.AttributeCollectionWithDynamicTypeDiag(req.Path)) + } +} diff --git a/datasource/schema/list_nested_attribute_test.go b/datasource/schema/list_nested_attribute_test.go index 722ad1c58..5fb035b29 100644 --- a/datasource/schema/list_nested_attribute_test.go +++ b/datasource/schema/list_nested_attribute_test.go @@ -4,6 +4,7 @@ package schema_test import ( + "context" "fmt" "strings" "testing" @@ -11,8 +12,11 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/testing/testschema" + "github.com/hashicorp/terraform-plugin-framework/internal/testing/testtypes" + "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-go/tftypes" @@ -604,3 +608,83 @@ func TestListNestedAttributeListValidators(t *testing.T) { }) } } + +func TestListNestedAttributeValidateImplementation(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attribute schema.ListNestedAttribute + request fwschema.ValidateImplementationRequest + expected *fwschema.ValidateImplementationResponse + }{ + "customtype": { + attribute: schema.ListNestedAttribute{ + Computed: true, + CustomType: testtypes.ListType{}, + }, + request: fwschema.ValidateImplementationRequest{ + Name: "test", + Path: path.Root("test"), + }, + expected: &fwschema.ValidateImplementationResponse{}, + }, + "nestedobject": { + attribute: schema.ListNestedAttribute{ + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "test_attr": schema.StringAttribute{ + Computed: true, + }, + }, + }, + }, + request: fwschema.ValidateImplementationRequest{ + Name: "test", + Path: path.Root("test"), + }, + expected: &fwschema.ValidateImplementationResponse{}, + }, + "nestedobject-dynamic": { + attribute: schema.ListNestedAttribute{ + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "test_dyn": schema.DynamicAttribute{ + Computed: true, + }, + }, + }, + }, + request: fwschema.ValidateImplementationRequest{ + Name: "test", + Path: path.Root("test"), + }, + expected: &fwschema.ValidateImplementationResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Schema Implementation", + "When validating the schema, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "\"test\" is an attribute that contains a collection type with a nested dynamic type.\n\n"+ + "Dynamic types inside of collections are not currently supported in terraform-plugin-framework. "+ + "If underlying dynamic values are required, replace the \"test\" attribute definition with DynamicAttribute instead.", + ), + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := &fwschema.ValidateImplementationResponse{} + testCase.attribute.ValidateImplementation(context.Background(), testCase.request, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/datasource/schema/list_nested_block.go b/datasource/schema/list_nested_block.go index 0722d54d4..4d098bc2d 100644 --- a/datasource/schema/list_nested_block.go +++ b/datasource/schema/list_nested_block.go @@ -4,11 +4,13 @@ package schema import ( + "context" "fmt" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema/fwxschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwtype" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" @@ -17,8 +19,9 @@ import ( // Ensure the implementation satisifies the desired interfaces. var ( - _ Block = ListNestedBlock{} - _ fwxschema.BlockWithListValidators = ListNestedBlock{} + _ Block = ListNestedBlock{} + _ fwschema.BlockWithValidateImplementation = ListNestedBlock{} + _ fwxschema.BlockWithListValidators = ListNestedBlock{} ) // ListNestedBlock represents a block that is a list of objects where @@ -55,6 +58,10 @@ var ( type ListNestedBlock struct { // NestedObject is the underlying object that contains nested attributes or // blocks. This field must be set. + // + // Nested attributes that contain a dynamic type (i.e. DynamicAttribute) are not supported. + // If underlying dynamic values are required, replace this block definition with + // a DynamicAttribute. NestedObject NestedBlockObject // CustomType enables the use of a custom attribute type in place of the @@ -186,3 +193,13 @@ func (b ListNestedBlock) Type() attr.Type { ElemType: b.NestedObject.Type(), } } + +// ValidateImplementation contains logic for validating the +// provider-defined implementation of the block to prevent unexpected +// errors or panics. This logic runs during the GetProviderSchema RPC and +// should never include false positives. +func (b ListNestedBlock) ValidateImplementation(ctx context.Context, req fwschema.ValidateImplementationRequest, resp *fwschema.ValidateImplementationResponse) { + if b.CustomType == nil && fwtype.ContainsCollectionWithDynamic(b.Type()) { + resp.Diagnostics.Append(fwtype.BlockCollectionWithDynamicTypeDiag(req.Path)) + } +} diff --git a/datasource/schema/list_nested_block_test.go b/datasource/schema/list_nested_block_test.go index 6e2f49b8d..b1ac538b7 100644 --- a/datasource/schema/list_nested_block_test.go +++ b/datasource/schema/list_nested_block_test.go @@ -4,6 +4,7 @@ package schema_test import ( + "context" "fmt" "strings" "testing" @@ -11,8 +12,11 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/testing/testschema" + "github.com/hashicorp/terraform-plugin-framework/internal/testing/testtypes" + "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-go/tftypes" @@ -485,3 +489,82 @@ func TestListNestedBlockType(t *testing.T) { }) } } + +func TestListNestedBlockValidateImplementation(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + block schema.ListNestedBlock + request fwschema.ValidateImplementationRequest + expected *fwschema.ValidateImplementationResponse + }{ + "customtype": { + block: schema.ListNestedBlock{ + CustomType: testtypes.ListType{}, + }, + request: fwschema.ValidateImplementationRequest{ + Name: "test", + Path: path.Root("test"), + }, + expected: &fwschema.ValidateImplementationResponse{}, + }, + "nestedobject": { + block: schema.ListNestedBlock{ + NestedObject: schema.NestedBlockObject{ + Attributes: map[string]schema.Attribute{ + "test_attr": schema.StringAttribute{ + Computed: true, + }, + }, + }, + }, + request: fwschema.ValidateImplementationRequest{ + Name: "test", + Path: path.Root("test"), + }, + expected: &fwschema.ValidateImplementationResponse{}, + }, + "nestedobject-dynamic": { + block: schema.ListNestedBlock{ + NestedObject: schema.NestedBlockObject{ + Attributes: map[string]schema.Attribute{ + "test_dyn": schema.DynamicAttribute{ + Computed: true, + }, + }, + }, + }, + request: fwschema.ValidateImplementationRequest{ + Name: "test", + Path: path.Root("test"), + }, + expected: &fwschema.ValidateImplementationResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Schema Implementation", + "When validating the schema, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "\"test\" is a block that contains a collection type with a nested dynamic type.\n\n"+ + "Dynamic types inside of collections are not currently supported in terraform-plugin-framework. "+ + "If underlying dynamic values are required, replace the \"test\" block definition with a DynamicAttribute.", + ), + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := &fwschema.ValidateImplementationResponse{} + testCase.block.ValidateImplementation(context.Background(), testCase.request, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/datasource/schema/map_attribute.go b/datasource/schema/map_attribute.go index ab4fe6859..d9b701f73 100644 --- a/datasource/schema/map_attribute.go +++ b/datasource/schema/map_attribute.go @@ -9,6 +9,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema/fwxschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwtype" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" @@ -47,6 +48,10 @@ var ( type MapAttribute struct { // ElementType is the type for all elements of the map. This field must be // set. + // + // Element types that contain a dynamic type (i.e. types.Dynamic) are not supported. + // If underlying dynamic values are required, replace this attribute definition with + // DynamicAttribute instead. ElementType attr.Type // CustomType enables the use of a custom attribute type in place of the @@ -213,4 +218,8 @@ func (a MapAttribute) ValidateImplementation(ctx context.Context, req fwschema.V if a.CustomType == nil && a.ElementType == nil { resp.Diagnostics.Append(fwschema.AttributeMissingElementTypeDiag(req.Path)) } + + if a.CustomType == nil && fwtype.ContainsCollectionWithDynamic(a.GetType()) { + resp.Diagnostics.Append(fwtype.AttributeCollectionWithDynamicTypeDiag(req.Path)) + } } diff --git a/datasource/schema/map_attribute_test.go b/datasource/schema/map_attribute_test.go index 171aa8ae2..6abffa4bb 100644 --- a/datasource/schema/map_attribute_test.go +++ b/datasource/schema/map_attribute_test.go @@ -462,6 +462,28 @@ func TestMapAttributeValidateImplementation(t *testing.T) { }, expected: &fwschema.ValidateImplementationResponse{}, }, + "elementtype-dynamic": { + attribute: schema.MapAttribute{ + Computed: true, + ElementType: types.DynamicType, + }, + request: fwschema.ValidateImplementationRequest{ + Name: "test", + Path: path.Root("test"), + }, + expected: &fwschema.ValidateImplementationResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Schema Implementation", + "When validating the schema, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "\"test\" is an attribute that contains a collection type with a nested dynamic type.\n\n"+ + "Dynamic types inside of collections are not currently supported in terraform-plugin-framework. "+ + "If underlying dynamic values are required, replace the \"test\" attribute definition with DynamicAttribute instead.", + ), + }, + }, + }, "elementtype-missing": { attribute: schema.MapAttribute{ Computed: true, diff --git a/datasource/schema/map_nested_attribute.go b/datasource/schema/map_nested_attribute.go index 3359fe6fc..2f3a60ec5 100644 --- a/datasource/schema/map_nested_attribute.go +++ b/datasource/schema/map_nested_attribute.go @@ -4,6 +4,7 @@ package schema import ( + "context" "fmt" "github.com/hashicorp/terraform-plugin-go/tftypes" @@ -11,6 +12,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema/fwxschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwtype" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" @@ -18,8 +20,9 @@ import ( // Ensure the implementation satisifies the desired interfaces. var ( - _ NestedAttribute = MapNestedAttribute{} - _ fwxschema.AttributeWithMapValidators = MapNestedAttribute{} + _ NestedAttribute = MapNestedAttribute{} + _ fwschema.AttributeWithValidateImplementation = MapNestedAttribute{} + _ fwxschema.AttributeWithMapValidators = MapNestedAttribute{} ) // MapNestedAttribute represents an attribute that is a set of objects where @@ -52,6 +55,10 @@ var ( type MapNestedAttribute struct { // NestedObject is the underlying object that contains nested attributes. // This field must be set. + // + // Nested attributes that contain a dynamic type (i.e. DynamicAttribute) are not supported. + // If underlying dynamic values are required, replace this attribute definition with + // DynamicAttribute instead. NestedObject NestedAttributeObject // CustomType enables the use of a custom attribute type in place of the @@ -228,3 +235,13 @@ func (a MapNestedAttribute) IsSensitive() bool { func (a MapNestedAttribute) MapValidators() []validator.Map { return a.Validators } + +// ValidateImplementation contains logic for validating the +// provider-defined implementation of the attribute to prevent unexpected +// errors or panics. This logic runs during the GetProviderSchema RPC and +// should never include false positives. +func (a MapNestedAttribute) ValidateImplementation(ctx context.Context, req fwschema.ValidateImplementationRequest, resp *fwschema.ValidateImplementationResponse) { + if a.CustomType == nil && fwtype.ContainsCollectionWithDynamic(a.GetType()) { + resp.Diagnostics.Append(fwtype.AttributeCollectionWithDynamicTypeDiag(req.Path)) + } +} diff --git a/datasource/schema/map_nested_attribute_test.go b/datasource/schema/map_nested_attribute_test.go index 9f25a1ab3..7e6dbc2a0 100644 --- a/datasource/schema/map_nested_attribute_test.go +++ b/datasource/schema/map_nested_attribute_test.go @@ -4,6 +4,7 @@ package schema_test import ( + "context" "fmt" "strings" "testing" @@ -11,8 +12,11 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/testing/testschema" + "github.com/hashicorp/terraform-plugin-framework/internal/testing/testtypes" + "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-go/tftypes" @@ -604,3 +608,83 @@ func TestMapNestedAttributeMapNestedValidators(t *testing.T) { }) } } + +func TestMapNestedAttributeValidateImplementation(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attribute schema.MapNestedAttribute + request fwschema.ValidateImplementationRequest + expected *fwschema.ValidateImplementationResponse + }{ + "customtype": { + attribute: schema.MapNestedAttribute{ + Computed: true, + CustomType: testtypes.MapType{}, + }, + request: fwschema.ValidateImplementationRequest{ + Name: "test", + Path: path.Root("test"), + }, + expected: &fwschema.ValidateImplementationResponse{}, + }, + "nestedobject": { + attribute: schema.MapNestedAttribute{ + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "test_attr": schema.StringAttribute{ + Computed: true, + }, + }, + }, + }, + request: fwschema.ValidateImplementationRequest{ + Name: "test", + Path: path.Root("test"), + }, + expected: &fwschema.ValidateImplementationResponse{}, + }, + "nestedobject-dynamic": { + attribute: schema.MapNestedAttribute{ + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "test_dyn": schema.DynamicAttribute{ + Computed: true, + }, + }, + }, + }, + request: fwschema.ValidateImplementationRequest{ + Name: "test", + Path: path.Root("test"), + }, + expected: &fwschema.ValidateImplementationResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Schema Implementation", + "When validating the schema, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "\"test\" is an attribute that contains a collection type with a nested dynamic type.\n\n"+ + "Dynamic types inside of collections are not currently supported in terraform-plugin-framework. "+ + "If underlying dynamic values are required, replace the \"test\" attribute definition with DynamicAttribute instead.", + ), + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := &fwschema.ValidateImplementationResponse{} + testCase.attribute.ValidateImplementation(context.Background(), testCase.request, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/datasource/schema/object_attribute.go b/datasource/schema/object_attribute.go index fcb800bdb..eafa40c6e 100644 --- a/datasource/schema/object_attribute.go +++ b/datasource/schema/object_attribute.go @@ -9,6 +9,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema/fwxschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwtype" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" @@ -46,6 +47,10 @@ var ( type ObjectAttribute struct { // AttributeTypes is the mapping of underlying attribute names to attribute // types. This field must be set. + // + // Attribute types that contain a collection with a nested dynamic type (i.e. types.List[types.Dynamic]) are not supported. + // If underlying dynamic collection values are required, replace this attribute definition with + // DynamicAttribute instead. AttributeTypes map[string]attr.Type // CustomType enables the use of a custom attribute type in place of the @@ -212,4 +217,8 @@ func (a ObjectAttribute) ValidateImplementation(ctx context.Context, req fwschem if a.AttributeTypes == nil && a.CustomType == nil { resp.Diagnostics.Append(fwschema.AttributeMissingAttributeTypesDiag(req.Path)) } + + if a.CustomType == nil && fwtype.ContainsCollectionWithDynamic(a.GetType()) { + resp.Diagnostics.Append(fwtype.AttributeCollectionWithDynamicTypeDiag(req.Path)) + } } diff --git a/datasource/schema/object_attribute_test.go b/datasource/schema/object_attribute_test.go index 9596e784f..e6139dc13 100644 --- a/datasource/schema/object_attribute_test.go +++ b/datasource/schema/object_attribute_test.go @@ -459,6 +459,53 @@ func TestObjectAttributeValidateImplementation(t *testing.T) { }, expected: &fwschema.ValidateImplementationResponse{}, }, + "attributetypes-dynamic": { + attribute: schema.ObjectAttribute{ + AttributeTypes: map[string]attr.Type{ + "test_attr": types.DynamicType, + "test_list": types.ListType{ + ElemType: types.StringType, + }, + "test_obj": types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "test_attr": types.DynamicType, + }, + }, + }, + Computed: true, + }, + request: fwschema.ValidateImplementationRequest{ + Name: "test", + Path: path.Root("test"), + }, + expected: &fwschema.ValidateImplementationResponse{}, + }, + "attributetypes-nested-collection-dynamic": { + attribute: schema.ObjectAttribute{ + AttributeTypes: map[string]attr.Type{ + "test_attr": types.ListType{ + ElemType: types.DynamicType, + }, + }, + Computed: true, + }, + request: fwschema.ValidateImplementationRequest{ + Name: "test", + Path: path.Root("test"), + }, + expected: &fwschema.ValidateImplementationResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Schema Implementation", + "When validating the schema, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "\"test\" is an attribute that contains a collection type with a nested dynamic type.\n\n"+ + "Dynamic types inside of collections are not currently supported in terraform-plugin-framework. "+ + "If underlying dynamic values are required, replace the \"test\" attribute definition with DynamicAttribute instead.", + ), + }, + }, + }, "attributetypes-missing": { attribute: schema.ObjectAttribute{ Computed: true, diff --git a/datasource/schema/set_attribute.go b/datasource/schema/set_attribute.go index be7128e95..261b02424 100644 --- a/datasource/schema/set_attribute.go +++ b/datasource/schema/set_attribute.go @@ -9,6 +9,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema/fwxschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwtype" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" @@ -42,6 +43,10 @@ var ( type SetAttribute struct { // ElementType is the type for all elements of the set. This field must be // set. + // + // Element types that contain a dynamic type (i.e. types.Dynamic) are not supported. + // If underlying dynamic values are required, replace this attribute definition with + // DynamicAttribute instead. ElementType attr.Type // CustomType enables the use of a custom attribute type in place of the @@ -208,4 +213,8 @@ func (a SetAttribute) ValidateImplementation(ctx context.Context, req fwschema.V if a.CustomType == nil && a.ElementType == nil { resp.Diagnostics.Append(fwschema.AttributeMissingElementTypeDiag(req.Path)) } + + if a.CustomType == nil && fwtype.ContainsCollectionWithDynamic(a.GetType()) { + resp.Diagnostics.Append(fwtype.AttributeCollectionWithDynamicTypeDiag(req.Path)) + } } diff --git a/datasource/schema/set_attribute_test.go b/datasource/schema/set_attribute_test.go index b62a31d3c..4d8f3c3f9 100644 --- a/datasource/schema/set_attribute_test.go +++ b/datasource/schema/set_attribute_test.go @@ -462,6 +462,28 @@ func TestSetAttributeValidateImplementation(t *testing.T) { }, expected: &fwschema.ValidateImplementationResponse{}, }, + "elementtype-dynamic": { + attribute: schema.SetAttribute{ + Computed: true, + ElementType: types.DynamicType, + }, + request: fwschema.ValidateImplementationRequest{ + Name: "test", + Path: path.Root("test"), + }, + expected: &fwschema.ValidateImplementationResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Schema Implementation", + "When validating the schema, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "\"test\" is an attribute that contains a collection type with a nested dynamic type.\n\n"+ + "Dynamic types inside of collections are not currently supported in terraform-plugin-framework. "+ + "If underlying dynamic values are required, replace the \"test\" attribute definition with DynamicAttribute instead.", + ), + }, + }, + }, "elementtype-missing": { attribute: schema.SetAttribute{ Computed: true, diff --git a/datasource/schema/set_nested_attribute.go b/datasource/schema/set_nested_attribute.go index d9acd9b63..860ab4c96 100644 --- a/datasource/schema/set_nested_attribute.go +++ b/datasource/schema/set_nested_attribute.go @@ -4,6 +4,7 @@ package schema import ( + "context" "fmt" "github.com/hashicorp/terraform-plugin-go/tftypes" @@ -11,6 +12,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema/fwxschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwtype" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" @@ -18,8 +20,9 @@ import ( // Ensure the implementation satisifies the desired interfaces. var ( - _ NestedAttribute = SetNestedAttribute{} - _ fwxschema.AttributeWithSetValidators = SetNestedAttribute{} + _ NestedAttribute = SetNestedAttribute{} + _ fwschema.AttributeWithValidateImplementation = SetNestedAttribute{} + _ fwxschema.AttributeWithSetValidators = SetNestedAttribute{} ) // SetNestedAttribute represents an attribute that is a set of objects where @@ -47,6 +50,10 @@ var ( type SetNestedAttribute struct { // NestedObject is the underlying object that contains nested attributes. // This field must be set. + // + // Nested attributes that contain a dynamic type (i.e. DynamicAttribute) are not supported. + // If underlying dynamic values are required, replace this attribute definition with + // DynamicAttribute instead. NestedObject NestedAttributeObject // CustomType enables the use of a custom attribute type in place of the @@ -223,3 +230,13 @@ func (a SetNestedAttribute) IsSensitive() bool { func (a SetNestedAttribute) SetValidators() []validator.Set { return a.Validators } + +// ValidateImplementation contains logic for validating the +// provider-defined implementation of the attribute to prevent unexpected +// errors or panics. This logic runs during the GetProviderSchema RPC and +// should never include false positives. +func (a SetNestedAttribute) ValidateImplementation(ctx context.Context, req fwschema.ValidateImplementationRequest, resp *fwschema.ValidateImplementationResponse) { + if a.CustomType == nil && fwtype.ContainsCollectionWithDynamic(a.GetType()) { + resp.Diagnostics.Append(fwtype.AttributeCollectionWithDynamicTypeDiag(req.Path)) + } +} diff --git a/datasource/schema/set_nested_attribute_test.go b/datasource/schema/set_nested_attribute_test.go index 716b56347..630a64863 100644 --- a/datasource/schema/set_nested_attribute_test.go +++ b/datasource/schema/set_nested_attribute_test.go @@ -4,6 +4,7 @@ package schema_test import ( + "context" "fmt" "strings" "testing" @@ -11,8 +12,11 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/testing/testschema" + "github.com/hashicorp/terraform-plugin-framework/internal/testing/testtypes" + "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-go/tftypes" @@ -604,3 +608,83 @@ func TestSetNestedAttributeSetValidators(t *testing.T) { }) } } + +func TestSetNestedAttributeValidateImplementation(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attribute schema.SetNestedAttribute + request fwschema.ValidateImplementationRequest + expected *fwschema.ValidateImplementationResponse + }{ + "customtype": { + attribute: schema.SetNestedAttribute{ + Computed: true, + CustomType: testtypes.SetType{}, + }, + request: fwschema.ValidateImplementationRequest{ + Name: "test", + Path: path.Root("test"), + }, + expected: &fwschema.ValidateImplementationResponse{}, + }, + "nestedobject": { + attribute: schema.SetNestedAttribute{ + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "test_attr": schema.StringAttribute{ + Computed: true, + }, + }, + }, + }, + request: fwschema.ValidateImplementationRequest{ + Name: "test", + Path: path.Root("test"), + }, + expected: &fwschema.ValidateImplementationResponse{}, + }, + "nestedobject-dynamic": { + attribute: schema.SetNestedAttribute{ + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "test_dyn": schema.DynamicAttribute{ + Computed: true, + }, + }, + }, + }, + request: fwschema.ValidateImplementationRequest{ + Name: "test", + Path: path.Root("test"), + }, + expected: &fwschema.ValidateImplementationResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Schema Implementation", + "When validating the schema, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "\"test\" is an attribute that contains a collection type with a nested dynamic type.\n\n"+ + "Dynamic types inside of collections are not currently supported in terraform-plugin-framework. "+ + "If underlying dynamic values are required, replace the \"test\" attribute definition with DynamicAttribute instead.", + ), + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := &fwschema.ValidateImplementationResponse{} + testCase.attribute.ValidateImplementation(context.Background(), testCase.request, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/datasource/schema/set_nested_block.go b/datasource/schema/set_nested_block.go index 8e89ff944..085163f37 100644 --- a/datasource/schema/set_nested_block.go +++ b/datasource/schema/set_nested_block.go @@ -4,11 +4,13 @@ package schema import ( + "context" "fmt" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema/fwxschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwtype" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" @@ -17,8 +19,9 @@ import ( // Ensure the implementation satisifies the desired interfaces. var ( - _ Block = SetNestedBlock{} - _ fwxschema.BlockWithSetValidators = SetNestedBlock{} + _ Block = SetNestedBlock{} + _ fwschema.BlockWithValidateImplementation = SetNestedBlock{} + _ fwxschema.BlockWithSetValidators = SetNestedBlock{} ) // SetNestedBlock represents a block that is a set of objects where @@ -55,6 +58,10 @@ var ( type SetNestedBlock struct { // NestedObject is the underlying object that contains nested attributes or // blocks. This field must be set. + // + // Nested attributes that contain a dynamic type (i.e. DynamicAttribute) are not supported. + // If underlying dynamic values are required, replace this block definition with + // a DynamicAttribute. NestedObject NestedBlockObject // CustomType enables the use of a custom attribute type in place of the @@ -186,3 +193,13 @@ func (b SetNestedBlock) Type() attr.Type { ElemType: b.NestedObject.Type(), } } + +// ValidateImplementation contains logic for validating the +// provider-defined implementation of the block to prevent unexpected +// errors or panics. This logic runs during the GetProviderSchema RPC and +// should never include false positives. +func (b SetNestedBlock) ValidateImplementation(ctx context.Context, req fwschema.ValidateImplementationRequest, resp *fwschema.ValidateImplementationResponse) { + if b.CustomType == nil && fwtype.ContainsCollectionWithDynamic(b.Type()) { + resp.Diagnostics.Append(fwtype.BlockCollectionWithDynamicTypeDiag(req.Path)) + } +} diff --git a/datasource/schema/set_nested_block_test.go b/datasource/schema/set_nested_block_test.go index b321766d9..988aee797 100644 --- a/datasource/schema/set_nested_block_test.go +++ b/datasource/schema/set_nested_block_test.go @@ -4,6 +4,7 @@ package schema_test import ( + "context" "fmt" "strings" "testing" @@ -11,8 +12,11 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/testing/testschema" + "github.com/hashicorp/terraform-plugin-framework/internal/testing/testtypes" + "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-go/tftypes" @@ -485,3 +489,82 @@ func TestSetNestedBlockType(t *testing.T) { }) } } + +func TestSetNestedBlockValidateImplementation(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + block schema.SetNestedBlock + request fwschema.ValidateImplementationRequest + expected *fwschema.ValidateImplementationResponse + }{ + "customtype": { + block: schema.SetNestedBlock{ + CustomType: testtypes.SetType{}, + }, + request: fwschema.ValidateImplementationRequest{ + Name: "test", + Path: path.Root("test"), + }, + expected: &fwschema.ValidateImplementationResponse{}, + }, + "nestedobject": { + block: schema.SetNestedBlock{ + NestedObject: schema.NestedBlockObject{ + Attributes: map[string]schema.Attribute{ + "test_attr": schema.StringAttribute{ + Computed: true, + }, + }, + }, + }, + request: fwschema.ValidateImplementationRequest{ + Name: "test", + Path: path.Root("test"), + }, + expected: &fwschema.ValidateImplementationResponse{}, + }, + "nestedobject-dynamic": { + block: schema.SetNestedBlock{ + NestedObject: schema.NestedBlockObject{ + Attributes: map[string]schema.Attribute{ + "test_dyn": schema.DynamicAttribute{ + Computed: true, + }, + }, + }, + }, + request: fwschema.ValidateImplementationRequest{ + Name: "test", + Path: path.Root("test"), + }, + expected: &fwschema.ValidateImplementationResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Schema Implementation", + "When validating the schema, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "\"test\" is a block that contains a collection type with a nested dynamic type.\n\n"+ + "Dynamic types inside of collections are not currently supported in terraform-plugin-framework. "+ + "If underlying dynamic values are required, replace the \"test\" block definition with a DynamicAttribute.", + ), + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := &fwschema.ValidateImplementationResponse{} + testCase.block.ValidateImplementation(context.Background(), testCase.request, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/function/arguments_data_test.go b/function/arguments_data_test.go index a4fdfe62a..7b8d2f83e 100644 --- a/function/arguments_data_test.go +++ b/function/arguments_data_test.go @@ -5,6 +5,7 @@ package function_test import ( "context" + "math/big" "testing" "github.com/google/go-cmp/cmp" @@ -245,6 +246,60 @@ func TestArgumentsDataGet(t *testing.T) { ), }, }, + "dynamic-framework-type": { + argumentsData: function.NewArgumentsData([]attr.Value{ + basetypes.NewDynamicValue(basetypes.NewStringValue("dynamic_test")), + basetypes.NewDynamicValue(basetypes.NewListValueMust( + basetypes.StringType{}, + []attr.Value{ + basetypes.NewStringValue("hello"), + basetypes.NewStringValue("dynamic"), + basetypes.NewStringValue("world"), + }, + )), + }), + targets: []any{ + new(basetypes.DynamicValue), + new(basetypes.DynamicValue), + }, + expected: []any{ + pointer(basetypes.NewDynamicValue(basetypes.NewStringValue("dynamic_test"))), + pointer(basetypes.NewDynamicValue(basetypes.NewListValueMust( + basetypes.StringType{}, + []attr.Value{ + basetypes.NewStringValue("hello"), + basetypes.NewStringValue("dynamic"), + basetypes.NewStringValue("world"), + }, + ))), + }, + }, + "dynamic-framework-type-variadic": { + argumentsData: function.NewArgumentsData([]attr.Value{ + basetypes.NewTupleValueMust( + []attr.Type{ + basetypes.DynamicType{}, + basetypes.DynamicType{}, + basetypes.DynamicType{}, + }, + []attr.Value{ + basetypes.NewDynamicValue(basetypes.NewStringValue("test1")), + basetypes.NewDynamicValue(basetypes.NewNumberValue(big.NewFloat(1.23))), + basetypes.NewDynamicValue(basetypes.NewBoolValue(true)), + }, + ), + }), + targets: []any{ + new([]basetypes.DynamicValue), + }, + expected: []any{ + pointer([]basetypes.DynamicValue{ + basetypes.NewDynamicValue(basetypes.NewStringValue("test1")), + basetypes.NewDynamicValue(basetypes.NewNumberValue(big.NewFloat(1.23))), + basetypes.NewDynamicValue(basetypes.NewBoolValue(true)), + }), + }, + }, "reflection": { argumentsData: function.NewArgumentsData([]attr.Value{ basetypes.NewBoolNull(), @@ -323,6 +378,9 @@ func TestArgumentsDataGet(t *testing.T) { cmp.Transformer("TupleValue", func(v *basetypes.TupleValue) basetypes.TupleValue { return *v }), + cmp.Transformer("DynamicValue", func(v *basetypes.DynamicValue) basetypes.DynamicValue { + return *v + }), } if diff := cmp.Diff(testCase.targets, testCase.expected, options...); diff != "" { diff --git a/function/definition.go b/function/definition.go index ea8699266..87dd45fb2 100644 --- a/function/definition.go +++ b/function/definition.go @@ -8,6 +8,7 @@ import ( "fmt" "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/internal/fwfunction" ) // Definition is a function definition. Always set at least the Result field. @@ -106,10 +107,18 @@ func (d Definition) ValidateImplementation(ctx context.Context, req DefinitionVa "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ fmt.Sprintf("Function %q - Definition return data type is undefined", req.FuncName), ) + } else if returnWithValidateImplementation, ok := d.Return.(fwfunction.ReturnWithValidateImplementation); ok { + req := fwfunction.ValidateReturnImplementationRequest{} + resp := &fwfunction.ValidateReturnImplementationResponse{} + + returnWithValidateImplementation.ValidateImplementation(ctx, req, resp) + + diags.Append(resp.Diagnostics...) } paramNames := make(map[string]int, len(d.Parameters)) for pos, param := range d.Parameters { + parameterPosition := int64(pos) name := param.GetName() // If name is not set, add an error diagnostic, parameter names are mandatory. if name == "" { @@ -121,6 +130,18 @@ func (d Definition) ValidateImplementation(ctx context.Context, req DefinitionVa ) } + if paramWithValidateImplementation, ok := param.(fwfunction.ParameterWithValidateImplementation); ok { + req := fwfunction.ValidateParameterImplementationRequest{ + Name: name, + ParameterPosition: ¶meterPosition, + } + resp := &fwfunction.ValidateParameterImplementationResponse{} + + paramWithValidateImplementation.ValidateImplementation(ctx, req, resp) + + diags.Append(resp.Diagnostics...) + } + conflictPos, exists := paramNames[name] if exists && name != "" { diags.AddError( @@ -148,6 +169,17 @@ func (d Definition) ValidateImplementation(ctx context.Context, req DefinitionVa ) } + if paramWithValidateImplementation, ok := d.VariadicParameter.(fwfunction.ParameterWithValidateImplementation); ok { + req := fwfunction.ValidateParameterImplementationRequest{ + Name: name, + } + resp := &fwfunction.ValidateParameterImplementationResponse{} + + paramWithValidateImplementation.ValidateImplementation(ctx, req, resp) + + diags.Append(resp.Diagnostics...) + } + conflictPos, exists := paramNames[name] if exists && name != "" { diags.AddError( diff --git a/function/definition_test.go b/function/definition_test.go index 46be02ea4..75d2b6679 100644 --- a/function/definition_test.go +++ b/function/definition_test.go @@ -11,6 +11,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/function" + "github.com/hashicorp/terraform-plugin-framework/types" ) func TestDefinitionParameter(t *testing.T) { @@ -238,6 +239,65 @@ func TestDefinitionValidateImplementation(t *testing.T) { }, }, }, + "param-dynamic-in-collection": { + definition: function.Definition{ + Parameters: []function.Parameter{ + function.MapParameter{ + ElementType: types.DynamicType, + }, + }, + Return: function.StringReturn{}, + }, + expected: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Function Definition", + "When validating the function definition, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "Parameter \"param1\" at position 0 contains a collection type with a nested dynamic type.\n\n"+ + "Dynamic types inside of collections are not currently supported in terraform-plugin-framework. "+ + "If underlying dynamic values are required, replace the \"param1\" parameter definition with DynamicParameter instead.", + ), + }, + }, + "variadic-param-dynamic-in-collection": { + definition: function.Definition{ + Parameters: []function.Parameter{ + function.StringParameter{}, + function.StringParameter{}, + }, + VariadicParameter: function.SetParameter{ + ElementType: types.DynamicType, + }, + Return: function.StringReturn{}, + }, + expected: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Function Definition", + "When validating the function definition, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "Variadic parameter \"varparam\" contains a collection type with a nested dynamic type.\n\n"+ + "Dynamic types inside of collections are not currently supported in terraform-plugin-framework. "+ + "If underlying dynamic values are required, replace the variadic parameter definition with DynamicParameter instead.", + ), + }, + }, + "return-dynamic-in-collection": { + definition: function.Definition{ + Return: function.ListReturn{ + ElementType: types.DynamicType, + }, + }, + expected: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Function Definition", + "When validating the function definition, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "Return contains a collection type with a nested dynamic type.\n\n"+ + "Dynamic types inside of collections are not currently supported in terraform-plugin-framework. "+ + "If underlying dynamic values are required, replace the return definition with DynamicReturn instead.", + ), + }, + }, "conflicting-param-names": { definition: function.Definition{ Parameters: []function.Parameter{ diff --git a/function/dynamic_parameter.go b/function/dynamic_parameter.go new file mode 100644 index 000000000..1af3c71d0 --- /dev/null +++ b/function/dynamic_parameter.go @@ -0,0 +1,102 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package function + +import ( + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" +) + +// Ensure the implementation satisifies the desired interfaces. +var _ Parameter = DynamicParameter{} + +// DynamicParameter represents a function parameter that is a dynamic, rather +// than a static type. Static types are always preferable over dynamic +// types in Terraform as practitioners will receive less helpful configuration +// assistance from validation error diagnostics and editor integrations. +// +// When retrieving the argument value for this parameter: +// +// - If CustomType is set, use its associated value type. +// - Otherwise, use the [types.Dynamic] value type. +// +// The concrete value type for a dynamic is determined at runtime by Terraform, +// if defined in the configuration. +type DynamicParameter struct { + // AllowNullValue when enabled denotes that a null argument value can be + // passed to the function. When disabled, Terraform returns an error if the + // argument value is null. + AllowNullValue bool + + // AllowUnknownValues when enabled denotes that an unknown argument value + // can be passed to the function. When disabled, Terraform skips the + // function call entirely and assumes an unknown value result from the + // function. + AllowUnknownValues bool + + // CustomType enables the use of a custom data type in place of the + // default [basetypes.DynamicType]. When retrieving data, the + // [basetypes.DynamicValuable] implementation associated with this custom + // type must be used in place of [types.Dynamic]. + CustomType basetypes.DynamicTypable + + // Description is used in various tooling, like the language server, to + // give practitioners more information about what this parameter is, + // what it is for, and how it should be used. It should be written as + // plain text, with no special formatting. + Description string + + // MarkdownDescription is used in various tooling, like the + // documentation generator, to give practitioners more information + // about what this parameter is, what it is for, and how it should be + // used. It should be formatted using Markdown. + MarkdownDescription string + + // Name is a short usage name for the parameter, such as "data". This name + // is used in documentation, such as generating a function signature, + // however its usage may be extended in the future. + // + // If no name is provided, this will default to "param" with a suffix of the + // position the parameter is in the function definition. ("param1", "param2", etc.) + // If the parameter is variadic, the default name will be "varparam". + // + // This must be a valid Terraform identifier, such as starting with an + // alphabetical character and followed by alphanumeric or underscore + // characters. + Name string +} + +// GetAllowNullValue returns if the parameter accepts a null value. +func (p DynamicParameter) GetAllowNullValue() bool { + return p.AllowNullValue +} + +// GetAllowUnknownValues returns if the parameter accepts an unknown value. +func (p DynamicParameter) GetAllowUnknownValues() bool { + return p.AllowUnknownValues +} + +// GetDescription returns the parameter plaintext description. +func (p DynamicParameter) GetDescription() string { + return p.Description +} + +// GetMarkdownDescription returns the parameter Markdown description. +func (p DynamicParameter) GetMarkdownDescription() string { + return p.MarkdownDescription +} + +// GetName returns the parameter name. +func (p DynamicParameter) GetName() string { + return p.Name +} + +// GetType returns the parameter data type. +func (p DynamicParameter) GetType() attr.Type { + if p.CustomType != nil { + return p.CustomType + } + + return basetypes.DynamicType{} +} diff --git a/function/dynamic_parameter_test.go b/function/dynamic_parameter_test.go new file mode 100644 index 000000000..a6e50c2b6 --- /dev/null +++ b/function/dynamic_parameter_test.go @@ -0,0 +1,242 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package function_test + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/function" + "github.com/hashicorp/terraform-plugin-framework/internal/testing/testtypes" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" +) + +func TestDynamicParameterGetAllowNullValue(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + parameter function.DynamicParameter + expected bool + }{ + "unset": { + parameter: function.DynamicParameter{}, + expected: false, + }, + "AllowNullValue-false": { + parameter: function.DynamicParameter{ + AllowNullValue: false, + }, + expected: false, + }, + "AllowNullValue-true": { + parameter: function.DynamicParameter{ + AllowNullValue: true, + }, + expected: true, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.parameter.GetAllowNullValue() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestDynamicParameterGetAllowUnknownValues(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + parameter function.DynamicParameter + expected bool + }{ + "unset": { + parameter: function.DynamicParameter{}, + expected: false, + }, + "AllowUnknownValues-false": { + parameter: function.DynamicParameter{ + AllowUnknownValues: false, + }, + expected: false, + }, + "AllowUnknownValues-true": { + parameter: function.DynamicParameter{ + AllowUnknownValues: true, + }, + expected: true, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.parameter.GetAllowUnknownValues() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestDynamicParameterGetDescription(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + parameter function.DynamicParameter + expected string + }{ + "unset": { + parameter: function.DynamicParameter{}, + expected: "", + }, + "Description-empty": { + parameter: function.DynamicParameter{ + Description: "", + }, + expected: "", + }, + "Description-nonempty": { + parameter: function.DynamicParameter{ + Description: "test description", + }, + expected: "test description", + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.parameter.GetDescription() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestDynamicParameterGetMarkdownDescription(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + parameter function.DynamicParameter + expected string + }{ + "unset": { + parameter: function.DynamicParameter{}, + expected: "", + }, + "MarkdownDescription-empty": { + parameter: function.DynamicParameter{ + MarkdownDescription: "", + }, + expected: "", + }, + "MarkdownDescription-nonempty": { + parameter: function.DynamicParameter{ + MarkdownDescription: "test description", + }, + expected: "test description", + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.parameter.GetMarkdownDescription() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestDynamicParameterGetName(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + parameter function.DynamicParameter + expected string + }{ + "unset": { + parameter: function.DynamicParameter{}, + expected: "", + }, + "Name-nonempty": { + parameter: function.DynamicParameter{ + Name: "test", + }, + expected: "test", + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.parameter.GetName() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestDynamicParameterGetType(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + parameter function.DynamicParameter + expected attr.Type + }{ + "unset": { + parameter: function.DynamicParameter{}, + expected: basetypes.DynamicType{}, + }, + "CustomType": { + parameter: function.DynamicParameter{ + CustomType: testtypes.DynamicType{}, + }, + expected: testtypes.DynamicType{}, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.parameter.GetType() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/function/dynamic_return.go b/function/dynamic_return.go new file mode 100644 index 000000000..bab38f857 --- /dev/null +++ b/function/dynamic_return.go @@ -0,0 +1,53 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package function + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" +) + +// Ensure the implementation satisifies the desired interfaces. +var _ Return = DynamicReturn{} + +// DynamicReturn represents a function return that is a dynamic, rather +// than a static type. Static types are always preferable over dynamic +// types in Terraform as practitioners will receive less helpful configuration +// assistance from validation error diagnostics and editor integrations. +// +// When setting the value for this return: +// +// - If CustomType is set, use its associated value type. +// - Otherwise, use the [types.Dynamic] value type. +type DynamicReturn struct { + // CustomType enables the use of a custom data type in place of the + // default [basetypes.DynamicType]. When setting data, the + // [basetypes.DynamicValuable] implementation associated with this custom + // type must be used in place of [types.Dynamic]. + CustomType basetypes.DynamicTypable +} + +// GetType returns the return data type. +func (r DynamicReturn) GetType() attr.Type { + if r.CustomType != nil { + return r.CustomType + } + + return basetypes.DynamicType{} +} + +// NewResultData returns a new result data based on the type. +func (r DynamicReturn) NewResultData(ctx context.Context) (ResultData, *FuncError) { + value := basetypes.NewDynamicUnknown() + + if r.CustomType == nil { + return NewResultData(value), nil + } + + valuable, diags := r.CustomType.ValueFromDynamic(ctx, value) + + return NewResultData(valuable), FuncErrorFromDiags(ctx, diags) +} diff --git a/function/dynamic_return_test.go b/function/dynamic_return_test.go new file mode 100644 index 000000000..1c1941050 --- /dev/null +++ b/function/dynamic_return_test.go @@ -0,0 +1,48 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package function_test + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/function" + "github.com/hashicorp/terraform-plugin-framework/internal/testing/testtypes" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" +) + +func TestDynamicReturnGetType(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + parameter function.DynamicReturn + expected attr.Type + }{ + "unset": { + parameter: function.DynamicReturn{}, + expected: basetypes.DynamicType{}, + }, + "CustomType": { + parameter: function.DynamicReturn{ + CustomType: testtypes.DynamicType{}, + }, + expected: testtypes.DynamicType{}, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.parameter.GetType() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/function/list_parameter.go b/function/list_parameter.go index f2c60d9c3..69d54da94 100644 --- a/function/list_parameter.go +++ b/function/list_parameter.go @@ -4,12 +4,20 @@ package function import ( + "context" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/internal/fwfunction" + "github.com/hashicorp/terraform-plugin-framework/internal/fwtype" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" ) // Ensure the implementation satisifies the desired interfaces. -var _ Parameter = ListParameter{} +var ( + _ Parameter = ListParameter{} + _ fwfunction.ParameterWithValidateImplementation = ListParameter{} +) // ListParameter represents a function parameter that is an ordered list of a // single element type. Either the ElementType or CustomType field must be set. @@ -27,6 +35,10 @@ var _ Parameter = ListParameter{} type ListParameter struct { // ElementType is the type for all elements of the list. This field must be // set. + // + // Element types that contain a dynamic type (i.e. types.Dynamic) are not supported. + // If underlying dynamic values are required, replace this parameter definition with + // DynamicParameter instead. ElementType attr.Type // AllowNullValue when enabled denotes that a null argument value can be @@ -107,3 +119,20 @@ func (p ListParameter) GetType() attr.Type { ElemType: p.ElementType, } } + +// ValidateImplementation contains logic for validating the +// provider-defined implementation of the parameter to prevent unexpected +// errors or panics. This logic runs during the GetProviderSchema RPC and +// should never include false positives. +func (p ListParameter) ValidateImplementation(ctx context.Context, req fwfunction.ValidateParameterImplementationRequest, resp *fwfunction.ValidateParameterImplementationResponse) { + if p.CustomType == nil && fwtype.ContainsCollectionWithDynamic(p.GetType()) { + var diag diag.Diagnostic + if req.ParameterPosition != nil { + diag = fwtype.ParameterCollectionWithDynamicTypeDiag(*req.ParameterPosition, req.Name) + } else { + diag = fwtype.VariadicParameterCollectionWithDynamicTypeDiag(req.Name) + } + + resp.Diagnostics.Append(diag) + } +} diff --git a/function/list_parameter_test.go b/function/list_parameter_test.go index ae383fd1e..3736a65c8 100644 --- a/function/list_parameter_test.go +++ b/function/list_parameter_test.go @@ -4,12 +4,16 @@ package function_test 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/function" + "github.com/hashicorp/terraform-plugin-framework/internal/fwfunction" "github.com/hashicorp/terraform-plugin-framework/internal/testing/testtypes" + "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" ) @@ -252,3 +256,90 @@ func TestListParameterGetType(t *testing.T) { }) } } + +func TestListParameterValidateImplementation(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + param function.ListParameter + request fwfunction.ValidateParameterImplementationRequest + expected *fwfunction.ValidateParameterImplementationResponse + }{ + "customtype": { + param: function.ListParameter{ + CustomType: testtypes.ListType{}, + }, + request: fwfunction.ValidateParameterImplementationRequest{ + ParameterPosition: pointer(int64(0)), + }, + expected: &fwfunction.ValidateParameterImplementationResponse{}, + }, + "elementtype": { + param: function.ListParameter{ + ElementType: types.StringType, + }, + request: fwfunction.ValidateParameterImplementationRequest{ + ParameterPosition: pointer(int64(0)), + }, + expected: &fwfunction.ValidateParameterImplementationResponse{}, + }, + "elementtype-dynamic": { + param: function.ListParameter{ + Name: "testparam", + ElementType: types.DynamicType, + }, + request: fwfunction.ValidateParameterImplementationRequest{ + Name: "testparam", + ParameterPosition: pointer(int64(0)), + }, + expected: &fwfunction.ValidateParameterImplementationResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Function Definition", + "When validating the function definition, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "Parameter \"testparam\" at position 0 contains a collection type with a nested dynamic type.\n\n"+ + "Dynamic types inside of collections are not currently supported in terraform-plugin-framework. "+ + "If underlying dynamic values are required, replace the \"testparam\" parameter definition with DynamicParameter instead.", + ), + }, + }, + }, + "elementtype-dynamic-variadic": { + param: function.ListParameter{ + Name: "testparam", + ElementType: types.DynamicType, + }, + request: fwfunction.ValidateParameterImplementationRequest{ + Name: "testparam", + }, + expected: &fwfunction.ValidateParameterImplementationResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Function Definition", + "When validating the function definition, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "Variadic parameter \"testparam\" contains a collection type with a nested dynamic type.\n\n"+ + "Dynamic types inside of collections are not currently supported in terraform-plugin-framework. "+ + "If underlying dynamic values are required, replace the variadic parameter definition with DynamicParameter instead.", + ), + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := &fwfunction.ValidateParameterImplementationResponse{} + testCase.param.ValidateImplementation(context.Background(), testCase.request, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/function/list_return.go b/function/list_return.go index 88238aee4..07eac8ad8 100644 --- a/function/list_return.go +++ b/function/list_return.go @@ -7,11 +7,16 @@ import ( "context" "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/internal/fwfunction" + "github.com/hashicorp/terraform-plugin-framework/internal/fwtype" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" ) // Ensure the implementation satisifies the desired interfaces. -var _ Return = ListReturn{} +var ( + _ Return = ListReturn{} + _ fwfunction.ReturnWithValidateImplementation = ListReturn{} +) // ListReturn represents a function return that is an ordered collection of a // single element type. Either the ElementType or CustomType field must be set. @@ -24,6 +29,10 @@ var _ Return = ListReturn{} type ListReturn struct { // ElementType is the type for all elements of the list. This field must be // set. + // + // Element types that contain a dynamic type (i.e. types.Dynamic) are not supported. + // If underlying dynamic values are required, replace this return definition with + // DynamicReturn instead. ElementType attr.Type // CustomType enables the use of a custom data type in place of the @@ -56,3 +65,13 @@ func (r ListReturn) NewResultData(ctx context.Context) (ResultData, *FuncError) return NewResultData(valuable), FuncErrorFromDiags(ctx, diags) } + +// ValidateImplementation contains logic for validating the +// provider-defined implementation of the Return to prevent unexpected +// errors or panics. This logic runs during the GetProviderSchema RPC and +// should never include false positives. +func (p ListReturn) ValidateImplementation(ctx context.Context, req fwfunction.ValidateReturnImplementationRequest, resp *fwfunction.ValidateReturnImplementationResponse) { + if p.CustomType == nil && fwtype.ContainsCollectionWithDynamic(p.GetType()) { + resp.Diagnostics.Append(fwtype.ReturnCollectionWithDynamicTypeDiag()) + } +} diff --git a/function/list_return_test.go b/function/list_return_test.go index 4839975ec..09985c13a 100644 --- a/function/list_return_test.go +++ b/function/list_return_test.go @@ -4,12 +4,16 @@ package function_test 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/function" + "github.com/hashicorp/terraform-plugin-framework/internal/fwfunction" "github.com/hashicorp/terraform-plugin-framework/internal/testing/testtypes" + "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" ) @@ -58,3 +62,61 @@ func TestListReturnGetType(t *testing.T) { }) } } + +func TestListReturnValidateImplementation(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + returnDef function.ListReturn + request fwfunction.ValidateReturnImplementationRequest + expected *fwfunction.ValidateReturnImplementationResponse + }{ + "customtype": { + returnDef: function.ListReturn{ + CustomType: testtypes.ListType{}, + }, + request: fwfunction.ValidateReturnImplementationRequest{}, + expected: &fwfunction.ValidateReturnImplementationResponse{}, + }, + "elementtype": { + returnDef: function.ListReturn{ + ElementType: types.StringType, + }, + request: fwfunction.ValidateReturnImplementationRequest{}, + expected: &fwfunction.ValidateReturnImplementationResponse{}, + }, + "elementtype-dynamic": { + returnDef: function.ListReturn{ + ElementType: types.DynamicType, + }, + request: fwfunction.ValidateReturnImplementationRequest{}, + expected: &fwfunction.ValidateReturnImplementationResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Function Definition", + "When validating the function definition, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "Return contains a collection type with a nested dynamic type.\n\n"+ + "Dynamic types inside of collections are not currently supported in terraform-plugin-framework. "+ + "If underlying dynamic values are required, replace the return definition with DynamicReturn instead.", + ), + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := &fwfunction.ValidateReturnImplementationResponse{} + testCase.returnDef.ValidateImplementation(context.Background(), testCase.request, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/function/map_parameter.go b/function/map_parameter.go index 2c7a7256f..457720ea2 100644 --- a/function/map_parameter.go +++ b/function/map_parameter.go @@ -4,12 +4,20 @@ package function import ( + "context" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/internal/fwfunction" + "github.com/hashicorp/terraform-plugin-framework/internal/fwtype" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" ) // Ensure the implementation satisifies the desired interfaces. -var _ Parameter = MapParameter{} +var ( + _ Parameter = MapParameter{} + _ fwfunction.ParameterWithValidateImplementation = MapParameter{} +) // MapParameter represents a function parameter that is a mapping of a single // element type. Either the ElementType or CustomType field must be set. @@ -27,6 +35,10 @@ var _ Parameter = MapParameter{} type MapParameter struct { // ElementType is the type for all elements of the map. This field must be // set. + // + // Element types that contain a dynamic type (i.e. types.Dynamic) are not supported. + // If underlying dynamic values are required, replace this parameter definition with + // DynamicParameter instead. ElementType attr.Type // AllowNullValue when enabled denotes that a null argument value can be @@ -107,3 +119,20 @@ func (p MapParameter) GetType() attr.Type { ElemType: p.ElementType, } } + +// ValidateImplementation contains logic for validating the +// provider-defined implementation of the parameter to prevent unexpected +// errors or panics. This logic runs during the GetProviderSchema RPC and +// should never include false positives. +func (p MapParameter) ValidateImplementation(ctx context.Context, req fwfunction.ValidateParameterImplementationRequest, resp *fwfunction.ValidateParameterImplementationResponse) { + if p.CustomType == nil && fwtype.ContainsCollectionWithDynamic(p.GetType()) { + var diag diag.Diagnostic + if req.ParameterPosition != nil { + diag = fwtype.ParameterCollectionWithDynamicTypeDiag(*req.ParameterPosition, req.Name) + } else { + diag = fwtype.VariadicParameterCollectionWithDynamicTypeDiag(req.Name) + } + + resp.Diagnostics.Append(diag) + } +} diff --git a/function/map_parameter_test.go b/function/map_parameter_test.go index 83e6b8625..0992a5452 100644 --- a/function/map_parameter_test.go +++ b/function/map_parameter_test.go @@ -4,12 +4,16 @@ package function_test 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/function" + "github.com/hashicorp/terraform-plugin-framework/internal/fwfunction" "github.com/hashicorp/terraform-plugin-framework/internal/testing/testtypes" + "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" ) @@ -252,3 +256,90 @@ func TestMapParameterGetType(t *testing.T) { }) } } + +func TestMapParameterValidateImplementation(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + param function.MapParameter + request fwfunction.ValidateParameterImplementationRequest + expected *fwfunction.ValidateParameterImplementationResponse + }{ + "customtype": { + param: function.MapParameter{ + CustomType: testtypes.MapType{}, + }, + request: fwfunction.ValidateParameterImplementationRequest{ + ParameterPosition: pointer(int64(0)), + }, + expected: &fwfunction.ValidateParameterImplementationResponse{}, + }, + "elementtype": { + param: function.MapParameter{ + ElementType: types.StringType, + }, + request: fwfunction.ValidateParameterImplementationRequest{ + ParameterPosition: pointer(int64(0)), + }, + expected: &fwfunction.ValidateParameterImplementationResponse{}, + }, + "elementtype-dynamic": { + param: function.MapParameter{ + Name: "testparam", + ElementType: types.DynamicType, + }, + request: fwfunction.ValidateParameterImplementationRequest{ + Name: "testparam", + ParameterPosition: pointer(int64(0)), + }, + expected: &fwfunction.ValidateParameterImplementationResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Function Definition", + "When validating the function definition, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "Parameter \"testparam\" at position 0 contains a collection type with a nested dynamic type.\n\n"+ + "Dynamic types inside of collections are not currently supported in terraform-plugin-framework. "+ + "If underlying dynamic values are required, replace the \"testparam\" parameter definition with DynamicParameter instead.", + ), + }, + }, + }, + "elementtype-dynamic-variadic": { + param: function.MapParameter{ + Name: "testparam", + ElementType: types.DynamicType, + }, + request: fwfunction.ValidateParameterImplementationRequest{ + Name: "testparam", + }, + expected: &fwfunction.ValidateParameterImplementationResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Function Definition", + "When validating the function definition, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "Variadic parameter \"testparam\" contains a collection type with a nested dynamic type.\n\n"+ + "Dynamic types inside of collections are not currently supported in terraform-plugin-framework. "+ + "If underlying dynamic values are required, replace the variadic parameter definition with DynamicParameter instead.", + ), + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := &fwfunction.ValidateParameterImplementationResponse{} + testCase.param.ValidateImplementation(context.Background(), testCase.request, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/function/map_return.go b/function/map_return.go index afc8be0d8..5f83c69c3 100644 --- a/function/map_return.go +++ b/function/map_return.go @@ -7,11 +7,16 @@ import ( "context" "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/internal/fwfunction" + "github.com/hashicorp/terraform-plugin-framework/internal/fwtype" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" ) // Ensure the implementation satisifies the desired interfaces. -var _ Return = MapReturn{} +var ( + _ Return = MapReturn{} + _ fwfunction.ReturnWithValidateImplementation = MapReturn{} +) // MapReturn represents a function return that is an ordered collect of a // single element type. Either the ElementType or CustomType field must be set. @@ -24,6 +29,10 @@ var _ Return = MapReturn{} type MapReturn struct { // ElementType is the type for all elements of the map. This field must be // set. + // + // Element types that contain a dynamic type (i.e. types.Dynamic) are not supported. + // If underlying dynamic values are required, replace this return definition with + // DynamicReturn instead. ElementType attr.Type // CustomType enables the use of a custom data type in place of the @@ -56,3 +65,13 @@ func (r MapReturn) NewResultData(ctx context.Context) (ResultData, *FuncError) { return NewResultData(valuable), FuncErrorFromDiags(ctx, diags) } + +// ValidateImplementation contains logic for validating the +// provider-defined implementation of the Return to prevent unexpected +// errors or panics. This logic runs during the GetProviderSchema RPC and +// should never include false positives. +func (p MapReturn) ValidateImplementation(ctx context.Context, req fwfunction.ValidateReturnImplementationRequest, resp *fwfunction.ValidateReturnImplementationResponse) { + if p.CustomType == nil && fwtype.ContainsCollectionWithDynamic(p.GetType()) { + resp.Diagnostics.Append(fwtype.ReturnCollectionWithDynamicTypeDiag()) + } +} diff --git a/function/map_return_test.go b/function/map_return_test.go index 071607ec7..1e30f538c 100644 --- a/function/map_return_test.go +++ b/function/map_return_test.go @@ -4,12 +4,16 @@ package function_test 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/function" + "github.com/hashicorp/terraform-plugin-framework/internal/fwfunction" "github.com/hashicorp/terraform-plugin-framework/internal/testing/testtypes" + "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" ) @@ -58,3 +62,61 @@ func TestMapReturnGetType(t *testing.T) { }) } } + +func TestMapReturnValidateImplementation(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + returnDef function.MapReturn + request fwfunction.ValidateReturnImplementationRequest + expected *fwfunction.ValidateReturnImplementationResponse + }{ + "customtype": { + returnDef: function.MapReturn{ + CustomType: testtypes.MapType{}, + }, + request: fwfunction.ValidateReturnImplementationRequest{}, + expected: &fwfunction.ValidateReturnImplementationResponse{}, + }, + "elementtype": { + returnDef: function.MapReturn{ + ElementType: types.StringType, + }, + request: fwfunction.ValidateReturnImplementationRequest{}, + expected: &fwfunction.ValidateReturnImplementationResponse{}, + }, + "elementtype-dynamic": { + returnDef: function.MapReturn{ + ElementType: types.DynamicType, + }, + request: fwfunction.ValidateReturnImplementationRequest{}, + expected: &fwfunction.ValidateReturnImplementationResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Function Definition", + "When validating the function definition, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "Return contains a collection type with a nested dynamic type.\n\n"+ + "Dynamic types inside of collections are not currently supported in terraform-plugin-framework. "+ + "If underlying dynamic values are required, replace the return definition with DynamicReturn instead.", + ), + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := &fwfunction.ValidateReturnImplementationResponse{} + testCase.returnDef.ValidateImplementation(context.Background(), testCase.request, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/function/object_parameter.go b/function/object_parameter.go index 8b8791e46..e558a7549 100644 --- a/function/object_parameter.go +++ b/function/object_parameter.go @@ -4,12 +4,20 @@ package function import ( + "context" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/internal/fwfunction" + "github.com/hashicorp/terraform-plugin-framework/internal/fwtype" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" ) // Ensure the implementation satisifies the desired interfaces. -var _ Parameter = ObjectParameter{} +var ( + _ Parameter = ObjectParameter{} + _ fwfunction.ParameterWithValidateImplementation = ObjectParameter{} +) // ObjectParameter represents a function parameter that is a mapping of // defined attribute names to values. Either the AttributeTypes or CustomType @@ -29,6 +37,10 @@ var _ Parameter = ObjectParameter{} type ObjectParameter struct { // AttributeTypes is the mapping of underlying attribute names to attribute // types. This field must be set. + // + // Attribute types that contain a collection with a nested dynamic type (i.e. types.List[types.Dynamic]) are not supported. + // If underlying dynamic collection values are required, replace this parameter definition with + // DynamicParameter instead. AttributeTypes map[string]attr.Type // AllowNullValue when enabled denotes that a null argument value can be @@ -109,3 +121,20 @@ func (p ObjectParameter) GetType() attr.Type { AttrTypes: p.AttributeTypes, } } + +// ValidateImplementation contains logic for validating the +// provider-defined implementation of the parameter to prevent unexpected +// errors or panics. This logic runs during the GetProviderSchema RPC and +// should never include false positives. +func (p ObjectParameter) ValidateImplementation(ctx context.Context, req fwfunction.ValidateParameterImplementationRequest, resp *fwfunction.ValidateParameterImplementationResponse) { + if p.CustomType == nil && fwtype.ContainsCollectionWithDynamic(p.GetType()) { + var diag diag.Diagnostic + if req.ParameterPosition != nil { + diag = fwtype.ParameterCollectionWithDynamicTypeDiag(*req.ParameterPosition, req.Name) + } else { + diag = fwtype.VariadicParameterCollectionWithDynamicTypeDiag(req.Name) + } + + resp.Diagnostics.Append(diag) + } +} diff --git a/function/object_parameter_test.go b/function/object_parameter_test.go index 10358943b..d2794e5e6 100644 --- a/function/object_parameter_test.go +++ b/function/object_parameter_test.go @@ -4,12 +4,16 @@ package function_test 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/function" + "github.com/hashicorp/terraform-plugin-framework/internal/fwfunction" "github.com/hashicorp/terraform-plugin-framework/internal/testing/testtypes" + "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" ) @@ -260,3 +264,119 @@ func TestObjectParameterGetType(t *testing.T) { }) } } + +func TestObjectParameterValidateImplementation(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + param function.ObjectParameter + request fwfunction.ValidateParameterImplementationRequest + expected *fwfunction.ValidateParameterImplementationResponse + }{ + "customtype": { + param: function.ObjectParameter{ + CustomType: testtypes.ObjectType{}, + }, + request: fwfunction.ValidateParameterImplementationRequest{ + ParameterPosition: pointer(int64(0)), + }, + expected: &fwfunction.ValidateParameterImplementationResponse{}, + }, + "attributetypes": { + param: function.ObjectParameter{ + AttributeTypes: map[string]attr.Type{ + "test_attr": types.StringType, + }, + }, + request: fwfunction.ValidateParameterImplementationRequest{ + ParameterPosition: pointer(int64(0)), + }, + expected: &fwfunction.ValidateParameterImplementationResponse{}, + }, + "attributetypes-dynamic": { + param: function.ObjectParameter{ + AttributeTypes: map[string]attr.Type{ + "test_attr": types.DynamicType, + "test_list": types.ListType{ + ElemType: types.StringType, + }, + "test_obj": types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "test_attr": types.DynamicType, + }, + }, + }, + }, + request: fwfunction.ValidateParameterImplementationRequest{ + ParameterPosition: pointer(int64(0)), + }, + expected: &fwfunction.ValidateParameterImplementationResponse{}, + }, + "attributetypes-nested-collection-dynamic": { + param: function.ObjectParameter{ + Name: "testparam", + AttributeTypes: map[string]attr.Type{ + "test_attr": types.ListType{ + ElemType: types.DynamicType, + }, + }, + }, + request: fwfunction.ValidateParameterImplementationRequest{ + Name: "testparam", + ParameterPosition: pointer(int64(0)), + }, + expected: &fwfunction.ValidateParameterImplementationResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Function Definition", + "When validating the function definition, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "Parameter \"testparam\" at position 0 contains a collection type with a nested dynamic type.\n\n"+ + "Dynamic types inside of collections are not currently supported in terraform-plugin-framework. "+ + "If underlying dynamic values are required, replace the \"testparam\" parameter definition with DynamicParameter instead.", + ), + }, + }, + }, + "attributetypes-nested-collection-dynamic-variadic": { + param: function.ObjectParameter{ + Name: "testparam", + AttributeTypes: map[string]attr.Type{ + "test_attr": types.ListType{ + ElemType: types.DynamicType, + }, + }, + }, + request: fwfunction.ValidateParameterImplementationRequest{ + Name: "testparam", + }, + expected: &fwfunction.ValidateParameterImplementationResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Function Definition", + "When validating the function definition, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "Variadic parameter \"testparam\" contains a collection type with a nested dynamic type.\n\n"+ + "Dynamic types inside of collections are not currently supported in terraform-plugin-framework. "+ + "If underlying dynamic values are required, replace the variadic parameter definition with DynamicParameter instead.", + ), + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := &fwfunction.ValidateParameterImplementationResponse{} + testCase.param.ValidateImplementation(context.Background(), testCase.request, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/function/object_return.go b/function/object_return.go index 526ab96f1..201960f95 100644 --- a/function/object_return.go +++ b/function/object_return.go @@ -7,11 +7,16 @@ import ( "context" "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/internal/fwfunction" + "github.com/hashicorp/terraform-plugin-framework/internal/fwtype" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" ) // Ensure the implementation satisifies the desired interfaces. -var _ Return = ObjectReturn{} +var ( + _ Return = ObjectReturn{} + _ fwfunction.ReturnWithValidateImplementation = ObjectReturn{} +) // ObjectReturn represents a function return that is mapping of defined // attribute names to values. When setting the value for this return, use @@ -20,6 +25,10 @@ var _ Return = ObjectReturn{} type ObjectReturn struct { // AttributeTypes is the mapping of underlying attribute names to attribute // types. This field must be set. + // + // Attribute types that contain a collection with a nested dynamic type (i.e. types.List[types.Dynamic]) are not supported. + // If underlying dynamic collection values are required, replace this return definition with + // DynamicReturn instead. AttributeTypes map[string]attr.Type // CustomType enables the use of a custom data type in place of the @@ -52,3 +61,13 @@ func (r ObjectReturn) NewResultData(ctx context.Context) (ResultData, *FuncError return NewResultData(valuable), FuncErrorFromDiags(ctx, diags) } + +// ValidateImplementation contains logic for validating the +// provider-defined implementation of the Return to prevent unexpected +// errors or panics. This logic runs during the GetProviderSchema RPC and +// should never include false positives. +func (p ObjectReturn) ValidateImplementation(ctx context.Context, req fwfunction.ValidateReturnImplementationRequest, resp *fwfunction.ValidateReturnImplementationResponse) { + if p.CustomType == nil && fwtype.ContainsCollectionWithDynamic(p.GetType()) { + resp.Diagnostics.Append(fwtype.ReturnCollectionWithDynamicTypeDiag()) + } +} diff --git a/function/object_return_test.go b/function/object_return_test.go index 04c23e70f..12699b7f6 100644 --- a/function/object_return_test.go +++ b/function/object_return_test.go @@ -4,12 +4,16 @@ package function_test 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/function" + "github.com/hashicorp/terraform-plugin-framework/internal/fwfunction" "github.com/hashicorp/terraform-plugin-framework/internal/testing/testtypes" + "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" ) @@ -66,3 +70,84 @@ func TestObjectReturnGetType(t *testing.T) { }) } } + +func TestObjectReturnValidateImplementation(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + returnDef function.ObjectReturn + request fwfunction.ValidateReturnImplementationRequest + expected *fwfunction.ValidateReturnImplementationResponse + }{ + "customtype": { + returnDef: function.ObjectReturn{ + CustomType: testtypes.ObjectType{}, + }, + request: fwfunction.ValidateReturnImplementationRequest{}, + expected: &fwfunction.ValidateReturnImplementationResponse{}, + }, + "attributetypes": { + returnDef: function.ObjectReturn{ + AttributeTypes: map[string]attr.Type{ + "test_attr": types.StringType, + }, + }, + request: fwfunction.ValidateReturnImplementationRequest{}, + expected: &fwfunction.ValidateReturnImplementationResponse{}, + }, + "attributetypes-dynamic": { + returnDef: function.ObjectReturn{ + AttributeTypes: map[string]attr.Type{ + "test_attr": types.DynamicType, + "test_list": types.ListType{ + ElemType: types.StringType, + }, + "test_obj": types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "test_attr": types.DynamicType, + }, + }, + }, + }, + request: fwfunction.ValidateReturnImplementationRequest{}, + expected: &fwfunction.ValidateReturnImplementationResponse{}, + }, + "attributetypes-nested-collection-dynamic": { + returnDef: function.ObjectReturn{ + AttributeTypes: map[string]attr.Type{ + "test_attr": types.ListType{ + ElemType: types.DynamicType, + }, + }, + }, + request: fwfunction.ValidateReturnImplementationRequest{}, + expected: &fwfunction.ValidateReturnImplementationResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Function Definition", + "When validating the function definition, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "Return contains a collection type with a nested dynamic type.\n\n"+ + "Dynamic types inside of collections are not currently supported in terraform-plugin-framework. "+ + "If underlying dynamic values are required, replace the return definition with DynamicReturn instead.", + ), + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := &fwfunction.ValidateReturnImplementationResponse{} + testCase.returnDef.ValidateImplementation(context.Background(), testCase.request, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/function/set_parameter.go b/function/set_parameter.go index f920f05ef..0774c559f 100644 --- a/function/set_parameter.go +++ b/function/set_parameter.go @@ -4,12 +4,20 @@ package function import ( + "context" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/internal/fwfunction" + "github.com/hashicorp/terraform-plugin-framework/internal/fwtype" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" ) // Ensure the implementation satisifies the desired interfaces. -var _ Parameter = SetParameter{} +var ( + _ Parameter = SetParameter{} + _ fwfunction.ParameterWithValidateImplementation = SetParameter{} +) // SetParameter represents a function parameter that is an unordered set of a // single element type. Either the ElementType or CustomType field must be set. @@ -27,6 +35,10 @@ var _ Parameter = SetParameter{} type SetParameter struct { // ElementType is the type for all elements of the set. This field must be // set. + // + // Element types that contain a dynamic type (i.e. types.Dynamic) are not supported. + // If underlying dynamic values are required, replace this parameter definition with + // DynamicParameter instead. ElementType attr.Type // AllowNullValue when enabled denotes that a null argument value can be @@ -107,3 +119,20 @@ func (p SetParameter) GetType() attr.Type { ElemType: p.ElementType, } } + +// ValidateImplementation contains logic for validating the +// provider-defined implementation of the parameter to prevent unexpected +// errors or panics. This logic runs during the GetProviderSchema RPC and +// should never include false positives. +func (p SetParameter) ValidateImplementation(ctx context.Context, req fwfunction.ValidateParameterImplementationRequest, resp *fwfunction.ValidateParameterImplementationResponse) { + if p.CustomType == nil && fwtype.ContainsCollectionWithDynamic(p.GetType()) { + var diag diag.Diagnostic + if req.ParameterPosition != nil { + diag = fwtype.ParameterCollectionWithDynamicTypeDiag(*req.ParameterPosition, req.Name) + } else { + diag = fwtype.VariadicParameterCollectionWithDynamicTypeDiag(req.Name) + } + + resp.Diagnostics.Append(diag) + } +} diff --git a/function/set_parameter_test.go b/function/set_parameter_test.go index 29fd2820a..d0fc4fa5d 100644 --- a/function/set_parameter_test.go +++ b/function/set_parameter_test.go @@ -4,12 +4,16 @@ package function_test 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/function" + "github.com/hashicorp/terraform-plugin-framework/internal/fwfunction" "github.com/hashicorp/terraform-plugin-framework/internal/testing/testtypes" + "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" ) @@ -252,3 +256,90 @@ func TestSetParameterGetType(t *testing.T) { }) } } + +func TestSetParameterValidateImplementation(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + param function.SetParameter + request fwfunction.ValidateParameterImplementationRequest + expected *fwfunction.ValidateParameterImplementationResponse + }{ + "customtype": { + param: function.SetParameter{ + CustomType: testtypes.SetType{}, + }, + request: fwfunction.ValidateParameterImplementationRequest{ + ParameterPosition: pointer(int64(0)), + }, + expected: &fwfunction.ValidateParameterImplementationResponse{}, + }, + "elementtype": { + param: function.SetParameter{ + ElementType: types.StringType, + }, + request: fwfunction.ValidateParameterImplementationRequest{ + ParameterPosition: pointer(int64(0)), + }, + expected: &fwfunction.ValidateParameterImplementationResponse{}, + }, + "elementtype-dynamic": { + param: function.SetParameter{ + Name: "testparam", + ElementType: types.DynamicType, + }, + request: fwfunction.ValidateParameterImplementationRequest{ + Name: "testparam", + ParameterPosition: pointer(int64(0)), + }, + expected: &fwfunction.ValidateParameterImplementationResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Function Definition", + "When validating the function definition, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "Parameter \"testparam\" at position 0 contains a collection type with a nested dynamic type.\n\n"+ + "Dynamic types inside of collections are not currently supported in terraform-plugin-framework. "+ + "If underlying dynamic values are required, replace the \"testparam\" parameter definition with DynamicParameter instead.", + ), + }, + }, + }, + "elementtype-dynamic-variadic": { + param: function.SetParameter{ + Name: "testparam", + ElementType: types.DynamicType, + }, + request: fwfunction.ValidateParameterImplementationRequest{ + Name: "testparam", + }, + expected: &fwfunction.ValidateParameterImplementationResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Function Definition", + "When validating the function definition, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "Variadic parameter \"testparam\" contains a collection type with a nested dynamic type.\n\n"+ + "Dynamic types inside of collections are not currently supported in terraform-plugin-framework. "+ + "If underlying dynamic values are required, replace the variadic parameter definition with DynamicParameter instead.", + ), + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := &fwfunction.ValidateParameterImplementationResponse{} + testCase.param.ValidateImplementation(context.Background(), testCase.request, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/function/set_return.go b/function/set_return.go index 97ea8e79c..2999a4067 100644 --- a/function/set_return.go +++ b/function/set_return.go @@ -7,11 +7,16 @@ import ( "context" "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/internal/fwfunction" + "github.com/hashicorp/terraform-plugin-framework/internal/fwtype" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" ) // Ensure the implementation satisifies the desired interfaces. -var _ Return = SetReturn{} +var ( + _ Return = SetReturn{} + _ fwfunction.ReturnWithValidateImplementation = SetReturn{} +) // SetReturn represents a function return that is an unordered collection of a // single element type. Either the ElementType or CustomType field must be set. @@ -24,6 +29,10 @@ var _ Return = SetReturn{} type SetReturn struct { // ElementType is the type for all elements of the set. This field must be // set. + // + // Element types that contain a dynamic type (i.e. types.Dynamic) are not supported. + // If underlying dynamic values are required, replace this return definition with + // DynamicReturn instead. ElementType attr.Type // CustomType enables the use of a custom data type in place of the @@ -56,3 +65,13 @@ func (r SetReturn) NewResultData(ctx context.Context) (ResultData, *FuncError) { return NewResultData(valuable), FuncErrorFromDiags(ctx, diags) } + +// ValidateImplementation contains logic for validating the +// provider-defined implementation of the Return to prevent unexpected +// errors or panics. This logic runs during the GetProviderSchema RPC and +// should never include false positives. +func (p SetReturn) ValidateImplementation(ctx context.Context, req fwfunction.ValidateReturnImplementationRequest, resp *fwfunction.ValidateReturnImplementationResponse) { + if p.CustomType == nil && fwtype.ContainsCollectionWithDynamic(p.GetType()) { + resp.Diagnostics.Append(fwtype.ReturnCollectionWithDynamicTypeDiag()) + } +} diff --git a/function/set_return_test.go b/function/set_return_test.go index b53102fd1..a6452c071 100644 --- a/function/set_return_test.go +++ b/function/set_return_test.go @@ -4,12 +4,16 @@ package function_test 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/function" + "github.com/hashicorp/terraform-plugin-framework/internal/fwfunction" "github.com/hashicorp/terraform-plugin-framework/internal/testing/testtypes" + "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" ) @@ -58,3 +62,61 @@ func TestSetReturnGetType(t *testing.T) { }) } } + +func TestSetReturnValidateImplementation(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + returnDef function.SetReturn + request fwfunction.ValidateReturnImplementationRequest + expected *fwfunction.ValidateReturnImplementationResponse + }{ + "customtype": { + returnDef: function.SetReturn{ + CustomType: testtypes.SetType{}, + }, + request: fwfunction.ValidateReturnImplementationRequest{}, + expected: &fwfunction.ValidateReturnImplementationResponse{}, + }, + "elementtype": { + returnDef: function.SetReturn{ + ElementType: types.StringType, + }, + request: fwfunction.ValidateReturnImplementationRequest{}, + expected: &fwfunction.ValidateReturnImplementationResponse{}, + }, + "elementtype-dynamic": { + returnDef: function.SetReturn{ + ElementType: types.DynamicType, + }, + request: fwfunction.ValidateReturnImplementationRequest{}, + expected: &fwfunction.ValidateReturnImplementationResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Function Definition", + "When validating the function definition, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "Return contains a collection type with a nested dynamic type.\n\n"+ + "Dynamic types inside of collections are not currently supported in terraform-plugin-framework. "+ + "If underlying dynamic values are required, replace the return definition with DynamicReturn instead.", + ), + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := &fwfunction.ValidateReturnImplementationResponse{} + testCase.returnDef.ValidateImplementation(context.Background(), testCase.request, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/internal/fwfunction/doc.go b/internal/fwfunction/doc.go new file mode 100644 index 000000000..8acc6b889 --- /dev/null +++ b/internal/fwfunction/doc.go @@ -0,0 +1,6 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +// Package fwfunction contains shared interfaces and structures for implementing behaviors +// in Terraform Provider function implementations. +package fwfunction diff --git a/internal/fwfunction/parameter_validate_implementation.go b/internal/fwfunction/parameter_validate_implementation.go new file mode 100644 index 000000000..1e171f016 --- /dev/null +++ b/internal/fwfunction/parameter_validate_implementation.go @@ -0,0 +1,54 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package fwfunction + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/diag" +) + +// MAINTAINER NOTE: This interface doesn't need to be internal but we're initially keeping them +// private until we determine if they would be useful to expose as a public interface. + +// ParameterWithValidateImplementation is an optional interface on +// function.Parameter which enables validation of the provider-defined implementation +// for the function.Parameter. This logic runs during the GetProviderSchema RPC, or via +// provider-defined unit testing, to ensure the provider's definition is valid +// before further usage could cause other unexpected errors or panics. +type ParameterWithValidateImplementation interface { + // ValidateImplementation should contain the logic which validates + // the function.Parameter implementation. Since this logic can prevent the provider + // from being usable, it should be very targeted and defensive against + // false positives. + ValidateImplementation(context.Context, ValidateParameterImplementationRequest, *ValidateParameterImplementationResponse) +} + +// ValidateParameterImplementationRequest contains the information available +// during a ValidateImplementation call to validate the function.Parameter +// definition. ValidateParameterImplementationResponse is the type used for +// responses. +type ValidateParameterImplementationRequest struct { + // ParameterPosition is the position of the parameter in the function definition for reporting diagnostics. + // A parameter without a position (i.e. `nil`) is the variadic parameter. + ParameterPosition *int64 + + // Name is the provider-defined parameter name or the default parameter name for reporting diagnostics. + // + // MAINTAINER NOTE: Since parameter names are not required currently and can be defaulted by internal framework logic, + // we accept the Name in this validate request, rather than using `(function.Parameter).GetName()` for diagnostics, which + // could be empty. + Name string +} + +// ValidateParameterImplementationResponse contains the returned data from a +// ValidateImplementation method call to validate the function.Parameter +// implementation. ValidateParameterImplementationRequest is the type used for +// requests. +type ValidateParameterImplementationResponse struct { + // Diagnostics report errors or warnings related to validating the + // definition of the function.Parameter. An empty slice indicates success, with no + // warnings or errors generated. + Diagnostics diag.Diagnostics +} diff --git a/internal/fwfunction/return_validate_implementation.go b/internal/fwfunction/return_validate_implementation.go new file mode 100644 index 000000000..17de03cf5 --- /dev/null +++ b/internal/fwfunction/return_validate_implementation.go @@ -0,0 +1,43 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package fwfunction + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/diag" +) + +// MAINTAINER NOTE: This interface doesn't need to be internal but we're initially keeping them +// private until we determine if they would be useful to expose as a public interface. + +// ReturnWithValidateImplementation is an optional interface on +// function.Return which enables validation of the provider-defined implementation +// for the function.Return. This logic runs during the GetProviderSchema RPC, or via +// provider-defined unit testing, to ensure the provider's definition is valid +// before further usage could cause other unexpected errors or panics. +type ReturnWithValidateImplementation interface { + // ValidateImplementation should contain the logic which validates + // the function.Return implementation. Since this logic can prevent the provider + // from being usable, it should be very targeted and defensive against + // false positives. + ValidateImplementation(context.Context, ValidateReturnImplementationRequest, *ValidateReturnImplementationResponse) +} + +// ValidateReturnImplementationRequest contains the information available +// during a ValidateImplementation call to validate the function.Return +// definition. ValidateReturnImplementationResponse is the type used for +// responses. +type ValidateReturnImplementationRequest struct{} + +// ValidateReturnImplementationResponse contains the returned data from a +// ValidateImplementation method call to validate the function.Return +// implementation. ValidateReturnImplementationRequest is the type used for +// requests. +type ValidateReturnImplementationResponse struct { + // Diagnostics report errors or warnings related to validating the + // definition of the function.Return. An empty slice indicates success, with no + // warnings or errors generated. + Diagnostics diag.Diagnostics +} diff --git a/internal/fwschema/attribute_default.go b/internal/fwschema/attribute_default.go index ad30e1026..29e63a7bb 100644 --- a/internal/fwschema/attribute_default.go +++ b/internal/fwschema/attribute_default.go @@ -78,3 +78,11 @@ type AttributeWithStringDefaultValue interface { StringDefaultValue() defaults.String } + +// AttributeWithDynamicDefaultValue is an optional interface on Attribute which +// enables Dynamic default value support. +type AttributeWithDynamicDefaultValue interface { + Attribute + + DynamicDefaultValue() defaults.Dynamic +} diff --git a/internal/fwschema/errors.go b/internal/fwschema/errors.go index b70a0c805..21e7b065c 100644 --- a/internal/fwschema/errors.go +++ b/internal/fwschema/errors.go @@ -6,13 +6,17 @@ package fwschema import "errors" var ( - // ErrPathInsideAtomicAttribute is used with AttributeAtPath is called + // ErrPathInsideAtomicAttribute is used when AttributeAtPath is called // on a path that doesn't have a schema associated with it, because // it's an element, attribute, or block of a complex type, not a nested // attribute. ErrPathInsideAtomicAttribute = errors.New("path leads to element, attribute, or block of a schema.Attribute that has no schema associated with it") - // ErrPathIsBlock is used with AttributeAtPath is called on a path is a + // ErrPathIsBlock is used when AttributeAtPath is called on a path is a // block, not an attribute. Use blockAtPath on the path instead. ErrPathIsBlock = errors.New("path leads to block, not an attribute") + + // ErrPathInsideDynamicAttribute is used when AttributeAtPath is called on a path that doesn't + // have a schema associated with it because it's nested in a dynamic attribute. + ErrPathInsideDynamicAttribute = errors.New("path leads to element or attribute nested in a schema.DynamicAttribute") ) diff --git a/internal/fwschema/fwxschema/attribute_plan_modification.go b/internal/fwschema/fwxschema/attribute_plan_modification.go index 72a626cac..f4cb841de 100644 --- a/internal/fwschema/fwxschema/attribute_plan_modification.go +++ b/internal/fwschema/fwxschema/attribute_plan_modification.go @@ -88,3 +88,12 @@ type AttributeWithStringPlanModifiers interface { // StringPlanModifiers should return a list of String plan modifiers. StringPlanModifiers() []planmodifier.String } + +// AttributeWithDynamicPlanModifiers is an optional interface on Attribute which +// enables Dynamic plan modifier support. +type AttributeWithDynamicPlanModifiers interface { + fwschema.Attribute + + // DynamicPlanModifiers should return a list of Dynamic plan modifiers. + DynamicPlanModifiers() []planmodifier.Dynamic +} diff --git a/internal/fwschema/fwxschema/attribute_validation.go b/internal/fwschema/fwxschema/attribute_validation.go index 6e3b6805a..e8274de4d 100644 --- a/internal/fwschema/fwxschema/attribute_validation.go +++ b/internal/fwschema/fwxschema/attribute_validation.go @@ -88,3 +88,12 @@ type AttributeWithStringValidators interface { // StringValidators should return a list of String validators. StringValidators() []validator.String } + +// AttributeWithDynamicValidators is an optional interface on Attribute which +// enables Dynamic validation support. +type AttributeWithDynamicValidators interface { + fwschema.Attribute + + // DynamicValidators should return a list of Dynamic validators. + DynamicValidators() []validator.Dynamic +} diff --git a/internal/fwschema/schema.go b/internal/fwschema/schema.go index 63ddad814..cc51acd8e 100644 --- a/internal/fwschema/schema.go +++ b/internal/fwschema/schema.go @@ -14,6 +14,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/internal/totftypes" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" ) // Schema is the core interface required for data sources, providers, and @@ -131,7 +132,7 @@ func SchemaAttributeAtTerraformPath(ctx context.Context, s Schema, p *tftypes.At rawType, remaining, err := tftypes.WalkAttributePath(s, p) if err != nil { - return nil, fmt.Errorf("%v still remains in the path: %w", remaining, err) + return nil, checkErrForDynamic(rawType, remaining, err) } switch typ := rawType.(type) { @@ -200,7 +201,7 @@ func SchemaTypeAtTerraformPath(ctx context.Context, s Schema, p *tftypes.Attribu rawType, remaining, err := tftypes.WalkAttributePath(s, p) if err != nil { - return nil, fmt.Errorf("%v still remains in the path: %w", remaining, err) + return nil, checkErrForDynamic(rawType, remaining, err) } switch typ := rawType.(type) { @@ -238,3 +239,32 @@ func SchemaType(s Schema) attr.Type { return types.ObjectType{AttrTypes: attrTypes} } + +// checkErrForDynamic is a helper function that will always return an error. It will return +// an `ErrPathInsideDynamicAttribute` error if rawType: +// - Is a dynamic type +// - Is an attribute that has a dynamic type +func checkErrForDynamic(rawType any, remaining *tftypes.AttributePath, err error) error { + if rawType == nil { + return fmt.Errorf("%v still remains in the path: %w", remaining, err) + } + + // Check to see if we tried walking into a dynamic type (types.DynamicType) + _, isDynamic := rawType.(basetypes.DynamicTypable) + if isDynamic { + // If the type is dynamic there is no schema information underneath it, return an error to allow calling logic to safely skip + return ErrPathInsideDynamicAttribute + } + + // Check to see if we tried walking into an attribute with a dynamic type (schema.DynamicAttribute) + attr, ok := rawType.(Attribute) + if ok { + _, isDynamic := attr.GetType().(basetypes.DynamicTypable) + if isDynamic { + // If the attribute is dynamic there are no nested attributes underneath it, return an error to allow calling logic to safely skip + return ErrPathInsideDynamicAttribute + } + } + + return fmt.Errorf("%v still remains in the path: %w", remaining, err) +} diff --git a/internal/fwschema/schema_test.go b/internal/fwschema/schema_test.go index 45d070384..89d2ee2a9 100644 --- a/internal/fwschema/schema_test.go +++ b/internal/fwschema/schema_test.go @@ -5,14 +5,18 @@ package fwschema_test import ( "context" + "errors" "sort" + "strings" "testing" "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/testing/testschema" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" ) func TestSchemaBlockPathExpressions(t *testing.T) { @@ -188,3 +192,359 @@ func TestSchemaBlockPathExpressions(t *testing.T) { }) } } + +func TestSchemaAttributeAtTerraformPath(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + schema fwschema.Schema + path *tftypes.AttributePath + expected fwschema.Attribute + expectedError error + }{ + "empty": { + schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{}, + }, + path: tftypes.NewAttributePath(), + expected: nil, + expectedError: errors.New("unexpected type testschema.Schema"), + }, + "string-attribute-exact": { + schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "test_attribute": testschema.Attribute{ + Required: true, + Type: types.StringType, + }, + }, + }, + path: tftypes.NewAttributePath().WithAttributeName("test_attribute"), + expected: testschema.Attribute{ + Required: true, + Type: types.StringType, + }, + }, + "string-attribute-ErrInvalidStep": { + schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "test_attribute": testschema.Attribute{ + Required: true, + Type: types.StringType, + }, + }, + }, + path: tftypes.NewAttributePath().WithAttributeName("test_attribute").WithElementKeyInt(0), + expected: nil, + expectedError: errors.New("ElementKeyInt(0) still remains in the path: cannot apply AttributePathStep tftypes.ElementKeyInt to basetypes.StringType"), + }, + "dynamic-attribute-exact": { + schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "test_attribute": testschema.Attribute{ + Required: true, + Type: types.DynamicType, + }, + }, + }, + path: tftypes.NewAttributePath().WithAttributeName("test_attribute"), + expected: testschema.Attribute{ + Required: true, + Type: types.DynamicType, + }, + }, + "dynamic-attribute-ErrPathInsideDynamicAttribute": { + schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "test_attribute": testschema.Attribute{ + Required: true, + Type: types.DynamicType, + }, + }, + }, + path: tftypes.NewAttributePath().WithAttributeName("test_attribute").WithElementKeyInt(0), + expected: nil, + expectedError: fwschema.ErrPathInsideDynamicAttribute, + }, + "object-attribute-exact": { + schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "test_attribute": testschema.Attribute{ + Required: true, + Type: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "dynamic": types.DynamicType, + }, + }, + }, + }, + }, + path: tftypes.NewAttributePath().WithAttributeName("test_attribute"), + expected: testschema.Attribute{ + Required: true, + Type: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "dynamic": types.DynamicType, + }, + }, + }, + }, + "object-attribute-dynamic-type-ErrPathInsideAtomicAttribute": { + schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "test_attribute": testschema.Attribute{ + Required: true, + Type: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "dynamic": types.DynamicType, + }, + }, + }, + }, + }, + path: tftypes.NewAttributePath().WithAttributeName("test_attribute").WithAttributeName("dynamic"), + expected: nil, + expectedError: fwschema.ErrPathInsideAtomicAttribute, + }, + "object-attribute-dynamic-type-ErrPathInsideDynamicAttribute": { + schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "test_attribute": testschema.Attribute{ + Required: true, + Type: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "dynamic": types.DynamicType, + }, + }, + }, + }, + }, + path: tftypes.NewAttributePath().WithAttributeName("test_attribute").WithAttributeName("dynamic").WithElementKeyInt(0), + expected: nil, + expectedError: fwschema.ErrPathInsideDynamicAttribute, + }, + "block-ErrPathIsBlock": { + schema: testschema.Schema{ + Blocks: map[string]fwschema.Block{ + "test_block": testschema.Block{ + NestedObject: testschema.NestedBlockObject{ + Attributes: map[string]fwschema.Attribute{ + "test_attribute": testschema.Attribute{ + Optional: true, + Type: types.StringType, + }, + }, + }, + NestingMode: fwschema.BlockNestingModeList, + }, + }, + }, + path: tftypes.NewAttributePath().WithAttributeName("test_block"), + expected: nil, + expectedError: fwschema.ErrPathIsBlock, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, err := fwschema.SchemaAttributeAtTerraformPath(context.Background(), testCase.schema, testCase.path) + + if err != nil { + if testCase.expectedError == nil { + t.Fatalf("expected no error, got: %s", err) + } + + if !strings.Contains(err.Error(), testCase.expectedError.Error()) { + t.Fatalf("expected error %q, got: %s", testCase.expectedError, err) + } + } + + if err == nil && testCase.expectedError != nil { + t.Fatalf("got no error, expected: %s", testCase.expectedError) + } + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("Unexpected result (+wanted, -got): %s", diff) + } + }) + } +} + +func TestSchemaTypeAtTerraformPath(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + schema fwschema.Schema + path *tftypes.AttributePath + expected attr.Type + expectedError error + }{ + "empty": { + schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{}, + }, + path: tftypes.NewAttributePath(), + expected: types.ObjectType{}, + }, + "string-attribute-exact": { + schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "test_attribute": testschema.Attribute{ + Required: true, + Type: types.StringType, + }, + }, + }, + path: tftypes.NewAttributePath().WithAttributeName("test_attribute"), + expected: types.StringType, + }, + "string-attribute-ErrInvalidStep": { + schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "test_attribute": testschema.Attribute{ + Required: true, + Type: types.StringType, + }, + }, + }, + path: tftypes.NewAttributePath().WithAttributeName("test_attribute").WithElementKeyInt(0), + expected: nil, + expectedError: errors.New("ElementKeyInt(0) still remains in the path: cannot apply AttributePathStep tftypes.ElementKeyInt to basetypes.StringType"), + }, + "dynamic-attribute-exact": { + schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "test_attribute": testschema.Attribute{ + Required: true, + Type: types.DynamicType, + }, + }, + }, + path: tftypes.NewAttributePath().WithAttributeName("test_attribute"), + expected: types.DynamicType, + }, + "dynamic-attribute-ErrPathInsideDynamicAttribute": { + schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "test_attribute": testschema.Attribute{ + Required: true, + Type: types.DynamicType, + }, + }, + }, + path: tftypes.NewAttributePath().WithAttributeName("test_attribute").WithElementKeyInt(0), + expected: nil, + expectedError: fwschema.ErrPathInsideDynamicAttribute, + }, + "object-attribute-exact": { + schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "test_attribute": testschema.Attribute{ + Required: true, + Type: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "dynamic": types.DynamicType, + }, + }, + }, + }, + }, + path: tftypes.NewAttributePath().WithAttributeName("test_attribute"), + expected: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "dynamic": types.DynamicType, + }, + }, + }, + "object-attribute-dynamic-type": { + schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "test_attribute": testschema.Attribute{ + Required: true, + Type: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "dynamic": types.DynamicType, + }, + }, + }, + }, + }, + path: tftypes.NewAttributePath().WithAttributeName("test_attribute").WithAttributeName("dynamic"), + expected: types.DynamicType, + }, + "object-attribute-dynamic-type-ErrPathInsideDynamicAttribute": { + schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "test_attribute": testschema.Attribute{ + Required: true, + Type: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "dynamic": types.DynamicType, + }, + }, + }, + }, + }, + path: tftypes.NewAttributePath().WithAttributeName("test_attribute").WithAttributeName("dynamic").WithElementKeyInt(0), + expected: nil, + expectedError: fwschema.ErrPathInsideDynamicAttribute, + }, + "block": { + schema: testschema.Schema{ + Blocks: map[string]fwschema.Block{ + "test_block": testschema.Block{ + NestedObject: testschema.NestedBlockObject{ + Attributes: map[string]fwschema.Attribute{ + "test_attribute": testschema.Attribute{ + Optional: true, + Type: types.StringType, + }, + }, + }, + NestingMode: fwschema.BlockNestingModeList, + }, + }, + }, + path: tftypes.NewAttributePath().WithAttributeName("test_block"), + expected: types.ListType{ + ElemType: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "test_attribute": types.StringType, + }, + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, err := fwschema.SchemaTypeAtTerraformPath(context.Background(), testCase.schema, testCase.path) + + if err != nil { + if testCase.expectedError == nil { + t.Fatalf("expected no error, got: %s", err) + } + + if !strings.Contains(err.Error(), testCase.expectedError.Error()) { + t.Fatalf("expected error %q, got: %s", testCase.expectedError, err) + } + } + + if err == nil && testCase.expectedError != nil { + t.Fatalf("got no error, expected: %s", testCase.expectedError) + } + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("Unexpected result (+wanted, -got): %s", diff) + } + }) + } +} diff --git a/internal/fwschemadata/data_default.go b/internal/fwschemadata/data_default.go index 75424bade..d83f5ee05 100644 --- a/internal/fwschemadata/data_default.go +++ b/internal/fwschemadata/data_default.go @@ -15,6 +15,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/logging" "github.com/hashicorp/terraform-plugin-framework/resource/schema/defaults" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" ) // TransformDefaults walks the schema and applies schema defined default values @@ -30,6 +31,35 @@ func (d *Data) TransformDefaults(ctx context.Context, configRaw tftypes.Value) d } d.TerraformValue, err = tftypes.Transform(d.TerraformValue, func(tfTypePath *tftypes.AttributePath, tfTypeValue tftypes.Value) (tftypes.Value, error) { + // Skip the root of the data, only applying defaults to attributes + if len(tfTypePath.Steps()) < 1 { + return tfTypeValue, nil + } + + attrAtPath, err := d.Schema.AttributeAtTerraformPath(ctx, tfTypePath) + + if err != nil { + if errors.Is(err, fwschema.ErrPathInsideAtomicAttribute) { + // ignore attributes/elements inside schema.Attributes, they have no schema of their own + logging.FrameworkTrace(ctx, "attribute is a non-schema attribute, not setting default") + return tfTypeValue, nil + } + + if errors.Is(err, fwschema.ErrPathIsBlock) { + // ignore blocks, they do not have a computed field + logging.FrameworkTrace(ctx, "attribute is a block, not setting default") + return tfTypeValue, nil + } + + if errors.Is(err, fwschema.ErrPathInsideDynamicAttribute) { + // ignore attributes/elements inside schema.DynamicAttribute, they have no schema of their own + logging.FrameworkTrace(ctx, "attribute is inside of a dynamic attribute, not setting default") + return tfTypeValue, nil + } + + return tftypes.Value{}, fmt.Errorf("couldn't find attribute in resource schema: %w", err) + } + fwPath, fwPathDiags := fromtftypes.AttributePath(ctx, tfTypePath, d.Schema) diags.Append(fwPathDiags...) @@ -51,25 +81,22 @@ func (d *Data) TransformDefaults(ctx context.Context, configRaw tftypes.Value) d // Do not transform if rawConfig value is not null. if !configValue.IsNull() { - return tfTypeValue, nil - } - - attrAtPath, err := d.Schema.AttributeAtTerraformPath(ctx, tfTypePath) - - if err != nil { - if errors.Is(err, fwschema.ErrPathInsideAtomicAttribute) { - // ignore attributes/elements inside schema.Attributes, they have no schema of their own - logging.FrameworkTrace(ctx, "attribute is a non-schema attribute, not setting default") + // Dynamic values need to perform more logic to check the config value for null-ness + dynValuable, ok := configValue.(basetypes.DynamicValuable) + if !ok { return tfTypeValue, nil } - if errors.Is(err, fwschema.ErrPathIsBlock) { - // ignore blocks, they do not have a computed field - logging.FrameworkTrace(ctx, "attribute is a block, not setting default") + dynConfigVal, dynDiags := dynValuable.ToDynamicValue(ctx) + if dynDiags.HasError() { return tfTypeValue, nil } - return tftypes.Value{}, fmt.Errorf("couldn't find attribute in resource schema: %w", err) + // For dynamic values, it's possible to be known when only the type is known. + // The underlying value can still be null, so check for that here + if !dynConfigVal.IsUnderlyingValueNull() { + return tfTypeValue, nil + } } switch a := attrAtPath.(type) { @@ -142,7 +169,6 @@ func (d *Data) TransformDefaults(ctx context.Context, configRaw tftypes.Value) d logging.FrameworkTrace(ctx, fmt.Sprintf("setting attribute %s to default value: %s", fwPath, resp.PlanValue)) return resp.PlanValue.ToTerraformValue(ctx) - case fwschema.AttributeWithListDefaultValue: defaultValue := a.ListDefaultValue() @@ -297,6 +323,29 @@ func (d *Data) TransformDefaults(ctx context.Context, configRaw tftypes.Value) d logging.FrameworkTrace(ctx, fmt.Sprintf("setting attribute %s to default value: %s", fwPath, resp.PlanValue)) + return resp.PlanValue.ToTerraformValue(ctx) + case fwschema.AttributeWithDynamicDefaultValue: + defaultValue := a.DynamicDefaultValue() + + if defaultValue == nil { + return tfTypeValue, nil + } + + req := defaults.DynamicRequest{ + Path: fwPath, + } + resp := defaults.DynamicResponse{} + + defaultValue.DefaultDynamic(ctx, req, &resp) + + diags.Append(resp.Diagnostics...) + + if resp.Diagnostics.HasError() { + return tfTypeValue, nil + } + + logging.FrameworkTrace(ctx, fmt.Sprintf("setting attribute %s to default value: %s", fwPath, resp.PlanValue)) + return resp.PlanValue.ToTerraformValue(ctx) } diff --git a/internal/fwschemadata/data_default_test.go b/internal/fwschemadata/data_default_test.go index 10f0b10d8..cb7bd4b47 100644 --- a/internal/fwschemadata/data_default_test.go +++ b/internal/fwschemadata/data_default_test.go @@ -22,6 +22,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" "github.com/hashicorp/terraform-plugin-framework/resource/schema/defaults" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/dynamicdefault" "github.com/hashicorp/terraform-plugin-framework/resource/schema/float64default" "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64default" "github.com/hashicorp/terraform-plugin-framework/resource/schema/listdefault" @@ -4087,6 +4088,560 @@ func TestDataDefault(t *testing.T) { ), }, }, + "dynamic-attribute-request-path": { + data: &fwschemadata.Data{ + Description: fwschemadata.DataDescriptionPlan, + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "dynamic_attribute": testschema.AttributeWithDynamicDefaultValue{ + Optional: true, + Computed: true, + Default: testdefaults.Dynamic{ + DefaultDynamicMethod: func(ctx context.Context, req defaults.DynamicRequest, resp *defaults.DynamicResponse) { + if !req.Path.Equal(path.Root("dynamic_attribute")) { + resp.Diagnostics.AddError( + "unexpected req.Path value", + fmt.Sprintf("expected %s, got: %s", path.Root("dynamic_attribute"), req.Path), + ) + } + }, + }, + }, + }, + }, + TerraformValue: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "dynamic_attribute": tftypes.DynamicPseudoType, + }, + }, + map[string]tftypes.Value{ + "dynamic_attribute": tftypes.NewValue(tftypes.DynamicPseudoType, nil), + }, + ), + }, + rawConfig: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "dynamic_attribute": tftypes.DynamicPseudoType, + }, + }, + map[string]tftypes.Value{ + "dynamic_attribute": tftypes.NewValue(tftypes.DynamicPseudoType, nil), + }, + ), + expected: &fwschemadata.Data{ + Description: fwschemadata.DataDescriptionPlan, + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "dynamic_attribute": testschema.AttributeWithDynamicDefaultValue{ + Optional: true, + Computed: true, + Default: testdefaults.Dynamic{ + DefaultDynamicMethod: func(ctx context.Context, req defaults.DynamicRequest, resp *defaults.DynamicResponse) { + if !req.Path.Equal(path.Root("dynamic_attribute")) { + resp.Diagnostics.AddError( + "unexpected req.Path value", + fmt.Sprintf("expected %s, got: %s", path.Root("dynamic_attribute"), req.Path), + ) + } + }, + }, + }, + }, + }, + TerraformValue: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "dynamic_attribute": tftypes.DynamicPseudoType, + }, + }, + map[string]tftypes.Value{ + "dynamic_attribute": tftypes.NewValue(tftypes.DynamicPseudoType, nil), + }, + ), + }, + }, + "dynamic-attribute-response-diagnostics": { + data: &fwschemadata.Data{ + Description: fwschemadata.DataDescriptionPlan, + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "dynamic_attribute": testschema.AttributeWithDynamicDefaultValue{ + Optional: true, + Computed: true, + Default: testdefaults.Dynamic{ + DefaultDynamicMethod: func(ctx context.Context, req defaults.DynamicRequest, resp *defaults.DynamicResponse) { + resp.Diagnostics.AddError("test error summary", "test error detail") + resp.Diagnostics.AddWarning("test warning summary", "test warning detail") + }, + }, + }, + }, + }, + TerraformValue: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "dynamic_attribute": tftypes.DynamicPseudoType, + }, + }, + map[string]tftypes.Value{ + "dynamic_attribute": tftypes.NewValue(tftypes.DynamicPseudoType, nil), + }, + ), + }, + rawConfig: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "dynamic_attribute": tftypes.DynamicPseudoType, + }, + }, + map[string]tftypes.Value{ + "dynamic_attribute": tftypes.NewValue(tftypes.DynamicPseudoType, nil), + }, + ), + expected: &fwschemadata.Data{ + Description: fwschemadata.DataDescriptionPlan, + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "dynamic_attribute": testschema.AttributeWithDynamicDefaultValue{ + Optional: true, + Computed: true, + Default: testdefaults.Dynamic{ + DefaultDynamicMethod: func(ctx context.Context, req defaults.DynamicRequest, resp *defaults.DynamicResponse) { + resp.Diagnostics.AddError("test error summary", "test error detail") + resp.Diagnostics.AddWarning("test warning summary", "test warning detail") + }, + }, + }, + }, + }, + TerraformValue: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "dynamic_attribute": tftypes.DynamicPseudoType, + }, + }, + map[string]tftypes.Value{ + "dynamic_attribute": tftypes.NewValue(tftypes.DynamicPseudoType, nil), + }, + ), + }, + expectedDiags: diag.Diagnostics{ + diag.NewErrorDiagnostic("test error summary", "test error detail"), + diag.NewWarningDiagnostic("test warning summary", "test warning detail"), + }, + }, + "dynamic-attribute-not-null-unmodified-default": { + data: &fwschemadata.Data{ + Description: fwschemadata.DataDescriptionState, + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "dynamic_attribute": testschema.AttributeWithDynamicDefaultValue{ + Computed: true, + Default: dynamicdefault.StaticValue(types.DynamicValue(types.StringValue("two"))), + }, + }, + }, + TerraformValue: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "dynamic_attribute": tftypes.DynamicPseudoType, + }, + }, + map[string]tftypes.Value{ + "dynamic_attribute": tftypes.NewValue(tftypes.String, "one"), + }, + ), + }, + rawConfig: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "dynamic_attribute": tftypes.DynamicPseudoType, + }, + }, + map[string]tftypes.Value{ + "dynamic_attribute": tftypes.NewValue(tftypes.String, "two"), // value in rawConfig + }, + ), + expected: &fwschemadata.Data{ + Description: fwschemadata.DataDescriptionState, + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "dynamic_attribute": testschema.AttributeWithDynamicDefaultValue{ + Computed: true, + Default: dynamicdefault.StaticValue(types.DynamicValue(types.StringValue("two"))), + }, + }, + }, + TerraformValue: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "dynamic_attribute": tftypes.DynamicPseudoType, + }, + }, + map[string]tftypes.Value{ + "dynamic_attribute": tftypes.NewValue(tftypes.String, "one"), + }, + ), + }, + }, + "dynamic-attribute-null-unmodified-no-default": { + data: &fwschemadata.Data{ + Description: fwschemadata.DataDescriptionState, + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "dynamic_attribute": testschema.Attribute{ + Computed: true, + Type: types.DynamicType, + }, + }, + }, + TerraformValue: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "dynamic_attribute": tftypes.DynamicPseudoType, + }, + }, + map[string]tftypes.Value{ + "dynamic_attribute": tftypes.NewValue( + tftypes.List{ + ElementType: tftypes.String, + }, + []tftypes.Value{ + // Default transform walk will visit both of these elements and skip + tftypes.NewValue(tftypes.String, "one"), + tftypes.NewValue(tftypes.String, "two"), + }, + ), + }, + ), + }, + rawConfig: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "dynamic_attribute": tftypes.DynamicPseudoType, + }, + }, + map[string]tftypes.Value{ + "dynamic_attribute": tftypes.NewValue(tftypes.DynamicPseudoType, nil), // value in rawConfig + }, + ), + expected: &fwschemadata.Data{ + Description: fwschemadata.DataDescriptionState, + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "dynamic_attribute": testschema.Attribute{ + Computed: true, + Type: types.DynamicType, + }, + }, + }, + TerraformValue: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "dynamic_attribute": tftypes.DynamicPseudoType, + }, + }, + map[string]tftypes.Value{ + "dynamic_attribute": tftypes.NewValue( + tftypes.List{ + ElementType: tftypes.String, + }, + []tftypes.Value{ + tftypes.NewValue(tftypes.String, "one"), + tftypes.NewValue(tftypes.String, "two"), + }, + ), + }, + ), + }, + }, + "dynamic-attribute-known-type-null-unmodified-no-default": { + data: &fwschemadata.Data{ + Description: fwschemadata.DataDescriptionState, + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "dynamic_attribute": testschema.Attribute{ + Computed: true, + Type: types.DynamicType, + }, + }, + }, + TerraformValue: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "dynamic_attribute": tftypes.DynamicPseudoType, + }, + }, + map[string]tftypes.Value{ + "dynamic_attribute": tftypes.NewValue(tftypes.String, "one"), + }, + ), + }, + rawConfig: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "dynamic_attribute": tftypes.DynamicPseudoType, + }, + }, + map[string]tftypes.Value{ + "dynamic_attribute": tftypes.NewValue(tftypes.String, nil), // value in rawConfig, type is known as String + }, + ), + expected: &fwschemadata.Data{ + Description: fwschemadata.DataDescriptionState, + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "dynamic_attribute": testschema.Attribute{ + Computed: true, + Type: types.DynamicType, + }, + }, + }, + TerraformValue: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "dynamic_attribute": tftypes.DynamicPseudoType, + }, + }, + map[string]tftypes.Value{ + "dynamic_attribute": tftypes.NewValue(tftypes.String, "one"), + }, + ), + }, + }, + "dynamic-attribute-null-modified-default": { + data: &fwschemadata.Data{ + Description: fwschemadata.DataDescriptionState, + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "dynamic_attribute": testschema.AttributeWithDynamicDefaultValue{ + Computed: true, + Default: dynamicdefault.StaticValue( + types.DynamicValue( + types.ListValueMust(types.StringType, []attr.Value{ + types.StringValue("three"), + types.StringValue("four"), + }), + ), + ), + }, + }, + }, + TerraformValue: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "dynamic_attribute": tftypes.DynamicPseudoType, + }, + }, + map[string]tftypes.Value{ + "dynamic_attribute": tftypes.NewValue( + tftypes.List{ + ElementType: tftypes.String, + }, + []tftypes.Value{ + // Default transform walk will visit both of these elements and skip + tftypes.NewValue(tftypes.String, "one"), + tftypes.NewValue(tftypes.String, "two"), + }, + ), + }, + ), + }, + rawConfig: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "dynamic_attribute": tftypes.DynamicPseudoType, + }, + }, + map[string]tftypes.Value{ + "dynamic_attribute": tftypes.NewValue(tftypes.DynamicPseudoType, nil), // value in rawConfig + }, + ), + expected: &fwschemadata.Data{ + Description: fwschemadata.DataDescriptionState, + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "dynamic_attribute": testschema.AttributeWithDynamicDefaultValue{ + Computed: true, + Default: dynamicdefault.StaticValue(types.DynamicValue(types.StringValue("two"))), + }, + }, + }, + TerraformValue: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "dynamic_attribute": tftypes.DynamicPseudoType, + }, + }, + map[string]tftypes.Value{ + "dynamic_attribute": tftypes.NewValue( + tftypes.List{ + ElementType: tftypes.String, + }, + []tftypes.Value{ + tftypes.NewValue(tftypes.String, "three"), + tftypes.NewValue(tftypes.String, "four"), + }, + ), + }, + ), + }, + }, + "dynamic-attribute-known-type-null-modified-default": { + data: &fwschemadata.Data{ + Description: fwschemadata.DataDescriptionState, + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "dynamic_attribute": testschema.AttributeWithDynamicDefaultValue{ + Computed: true, + Default: dynamicdefault.StaticValue(types.DynamicValue(types.StringValue("two"))), + }, + }, + }, + TerraformValue: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "dynamic_attribute": tftypes.DynamicPseudoType, + }, + }, + map[string]tftypes.Value{ + "dynamic_attribute": tftypes.NewValue(tftypes.String, "one"), + }, + ), + }, + rawConfig: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "dynamic_attribute": tftypes.DynamicPseudoType, + }, + }, + map[string]tftypes.Value{ + "dynamic_attribute": tftypes.NewValue(tftypes.String, nil), // value in rawConfig, type is known as String + }, + ), + expected: &fwschemadata.Data{ + Description: fwschemadata.DataDescriptionState, + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "dynamic_attribute": testschema.AttributeWithDynamicDefaultValue{ + Computed: true, + Default: dynamicdefault.StaticValue(types.DynamicValue(types.StringValue("two"))), + }, + }, + }, + TerraformValue: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "dynamic_attribute": tftypes.DynamicPseudoType, + }, + }, + map[string]tftypes.Value{ + "dynamic_attribute": tftypes.NewValue(tftypes.String, "two"), + }, + ), + }, + }, + "dynamic-attribute-null-unmodified-default-nil": { + data: &fwschemadata.Data{ + Description: fwschemadata.DataDescriptionState, + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "dynamic_attribute": testschema.AttributeWithDynamicDefaultValue{ + Computed: true, + Default: nil, + }, + }, + }, + TerraformValue: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "dynamic_attribute": tftypes.DynamicPseudoType, + }, + }, + map[string]tftypes.Value{ + "dynamic_attribute": tftypes.NewValue(tftypes.String, "one"), + }, + ), + }, + rawConfig: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "dynamic_attribute": tftypes.DynamicPseudoType, + }, + }, + map[string]tftypes.Value{ + "dynamic_attribute": tftypes.NewValue(tftypes.DynamicPseudoType, nil), // value in rawConfig + }, + ), + expected: &fwschemadata.Data{ + Description: fwschemadata.DataDescriptionState, + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "dynamic_attribute": testschema.AttributeWithDynamicDefaultValue{ + Computed: true, + Default: nil, + }, + }, + }, + TerraformValue: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "dynamic_attribute": tftypes.DynamicPseudoType, + }, + }, + map[string]tftypes.Value{ + "dynamic_attribute": tftypes.NewValue(tftypes.String, "one"), + }, + ), + }, + }, + "dynamic-attribute-known-type-null-unmodified-default-nil": { + data: &fwschemadata.Data{ + Description: fwschemadata.DataDescriptionState, + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "dynamic_attribute": testschema.AttributeWithDynamicDefaultValue{ + Computed: true, + Default: nil, + }, + }, + }, + TerraformValue: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "dynamic_attribute": tftypes.DynamicPseudoType, + }, + }, + map[string]tftypes.Value{ + "dynamic_attribute": tftypes.NewValue(tftypes.String, "one"), + }, + ), + }, + rawConfig: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "dynamic_attribute": tftypes.DynamicPseudoType, + }, + }, + map[string]tftypes.Value{ + "dynamic_attribute": tftypes.NewValue(tftypes.String, nil), // value in rawConfig, type is known as String + }, + ), + expected: &fwschemadata.Data{ + Description: fwschemadata.DataDescriptionState, + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "dynamic_attribute": testschema.AttributeWithDynamicDefaultValue{ + Computed: true, + Default: nil, + }, + }, + }, + TerraformValue: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "dynamic_attribute": tftypes.DynamicPseudoType, + }, + }, + map[string]tftypes.Value{ + "dynamic_attribute": tftypes.NewValue(tftypes.String, "one"), + }, + ), + }, + }, "list-nested-attribute-not-null-unmodified-default": { data: &fwschemadata.Data{ Description: fwschemadata.DataDescriptionState, diff --git a/internal/fwschemadata/data_get_at_path_test.go b/internal/fwschemadata/data_get_at_path_test.go index eaaa75872..cb8ad8522 100644 --- a/internal/fwschemadata/data_get_at_path_test.go +++ b/internal/fwschemadata/data_get_at_path_test.go @@ -6536,6 +6536,299 @@ func TestDataGetAtPath(t *testing.T) { target: new(string), expected: pointer("test"), }, + "DynamicType-types.dynamic-null": { + data: fwschemadata.Data{ + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "dynamic": testschema.Attribute{ + Optional: true, + Type: types.DynamicType, + }, + }, + }, + TerraformValue: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "dynamic": tftypes.DynamicPseudoType, + }, + }, + map[string]tftypes.Value{ + "dynamic": tftypes.NewValue(tftypes.DynamicPseudoType, nil), + }, + ), + }, + path: path.Root("dynamic"), + target: new(types.Dynamic), + expected: pointer(types.DynamicNull()), + }, + "DynamicType-types.dynamic-underlying-value-null": { + data: fwschemadata.Data{ + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "dynamic": testschema.Attribute{ + Optional: true, + Type: types.DynamicType, + }, + }, + }, + TerraformValue: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "dynamic": tftypes.DynamicPseudoType, + }, + }, + map[string]tftypes.Value{ + "dynamic": tftypes.NewValue(tftypes.Bool, nil), // Terraform knows the type, but the underlying value is null + }, + ), + }, + path: path.Root("dynamic"), + target: new(types.Dynamic), + expected: pointer(types.DynamicValue(types.BoolNull())), + }, + "DynamicType-types.dynamic-unknown": { + data: fwschemadata.Data{ + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "dynamic": testschema.Attribute{ + Optional: true, + Type: types.DynamicType, + }, + }, + }, + TerraformValue: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "dynamic": tftypes.DynamicPseudoType, + }, + }, + map[string]tftypes.Value{ + "dynamic": tftypes.NewValue(tftypes.DynamicPseudoType, tftypes.UnknownValue), + }, + ), + }, + path: path.Root("dynamic"), + target: new(types.Dynamic), + expected: pointer(types.DynamicUnknown()), + }, + "DynamicType-types.dynamic-underlying-value-unknown": { + data: fwschemadata.Data{ + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "dynamic": testschema.Attribute{ + Optional: true, + Type: types.DynamicType, + }, + }, + }, + TerraformValue: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "dynamic": tftypes.DynamicPseudoType, + }, + }, + map[string]tftypes.Value{ + "dynamic": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), // Terraform knows the type, but the underlying value is unknown + }, + ), + }, + path: path.Root("dynamic"), + target: new(types.Dynamic), + expected: pointer(types.DynamicValue(types.StringUnknown())), + }, + "DynamicType-types.dynamic-stringvalue": { + data: fwschemadata.Data{ + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "string": testschema.Attribute{ + Optional: true, + Type: types.DynamicType, + }, + }, + }, + TerraformValue: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "string": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "string": tftypes.NewValue(tftypes.String, "test"), + }, + ), + }, + path: path.Root("string"), + target: new(types.Dynamic), + expected: pointer(types.DynamicValue(types.StringValue("test"))), + }, + "DynamicType-types.dynamic-listvalue": { + data: fwschemadata.Data{ + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "list": testschema.Attribute{ + Optional: true, + Type: types.DynamicType, + }, + }, + }, + TerraformValue: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "list": tftypes.List{ + ElementType: tftypes.String, + }, + }, + }, + map[string]tftypes.Value{ + "list": tftypes.NewValue( + tftypes.List{ + ElementType: tftypes.String, + }, + []tftypes.Value{ + tftypes.NewValue(tftypes.String, "test1"), + tftypes.NewValue(tftypes.String, "test2"), + }, + ), + }, + ), + }, + path: path.Root("list"), + target: new(types.Dynamic), + expected: pointer(types.DynamicValue(types.ListValueMust( + types.StringType, + []attr.Value{ + types.StringValue("test1"), + types.StringValue("test2"), + }, + ))), + }, + "DynamicType-types.dynamic-mapvalue": { + data: fwschemadata.Data{ + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "map": testschema.Attribute{ + Optional: true, + Type: types.DynamicType, + }, + }, + }, + TerraformValue: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "map": tftypes.Map{ + ElementType: tftypes.String, + }, + }, + }, + map[string]tftypes.Value{ + "map": tftypes.NewValue( + tftypes.Map{ + ElementType: tftypes.String, + }, + map[string]tftypes.Value{ + "key1": tftypes.NewValue(tftypes.String, "value1"), + "key2": tftypes.NewValue(tftypes.String, "value2"), + }, + ), + }, + ), + }, + path: path.Root("map"), + target: new(types.Dynamic), + expected: pointer(types.DynamicValue(types.MapValueMust( + types.StringType, + map[string]attr.Value{ + "key1": types.StringValue("value1"), + "key2": types.StringValue("value2"), + }, + ))), + }, + "DynamicType-types.dynamic-objectvalue": { + data: fwschemadata.Data{ + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "object": testschema.Attribute{ + Optional: true, + Type: types.DynamicType, + }, + }, + }, + TerraformValue: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "object": tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "nested_string": tftypes.String, + }, + }, + }, + }, + map[string]tftypes.Value{ + "object": tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "nested_string": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "nested_string": tftypes.NewValue(tftypes.String, "test1"), + }, + ), + }, + ), + }, + path: path.Root("object"), + target: new(types.Dynamic), + expected: pointer(types.DynamicValue(types.ObjectValueMust( + map[string]attr.Type{ + "nested_string": types.StringType, + }, + map[string]attr.Value{ + "nested_string": types.StringValue("test1"), + }, + ))), + }, + "DynamicType-types.dynamic-setvalue": { + data: fwschemadata.Data{ + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "set": testschema.Attribute{ + Optional: true, + Type: types.DynamicType, + }, + }, + }, + TerraformValue: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "set": tftypes.Set{ + ElementType: tftypes.String, + }, + }, + }, + map[string]tftypes.Value{ + "set": tftypes.NewValue( + tftypes.Set{ + ElementType: tftypes.String, + }, + []tftypes.Value{ + tftypes.NewValue(tftypes.String, "test1"), + tftypes.NewValue(tftypes.String, "test2"), + }, + ), + }, + ), + }, + path: path.Root("set"), + target: new(types.Dynamic), + expected: pointer(types.DynamicValue(types.SetValueMust( + types.StringType, + []attr.Value{ + types.StringValue("test1"), + types.StringValue("test2"), + }, + ))), + }, } for name, tc := range testCases { @@ -6584,6 +6877,9 @@ func TestDataGetAtPath(t *testing.T) { cmp.Comparer(func(i, j *types.String) bool { return (i == nil && j == nil) || (i != nil && j != nil && cmp.Equal(*i, *j)) }), + cmp.Comparer(func(i, j *types.Dynamic) bool { + return (i == nil && j == nil) || (i != nil && j != nil && cmp.Equal(*i, *j)) + }), } if diff := cmp.Diff(tc.target, tc.expected, comparers...); diff != "" { diff --git a/internal/fwschemadata/data_get_test.go b/internal/fwschemadata/data_get_test.go index ed4be2189..85116667e 100644 --- a/internal/fwschemadata/data_get_test.go +++ b/internal/fwschemadata/data_get_test.go @@ -7252,6 +7252,344 @@ func TestDataGet(t *testing.T) { String: "test", }, }, + "DynamicType-types.dynamic-null": { + data: fwschemadata.Data{ + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "dynamic": testschema.Attribute{ + Optional: true, + Type: types.DynamicType, + }, + }, + }, + TerraformValue: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "dynamic": tftypes.DynamicPseudoType, + }, + }, + map[string]tftypes.Value{ + "dynamic": tftypes.NewValue(tftypes.DynamicPseudoType, nil), + }, + ), + }, + target: new(struct { + Dynamic types.Dynamic `tfsdk:"dynamic"` + }), + expected: &struct { + Dynamic types.Dynamic `tfsdk:"dynamic"` + }{ + Dynamic: types.DynamicNull(), + }, + }, + "DynamicType-types.dynamic-underlying-value-null": { + data: fwschemadata.Data{ + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "dynamic": testschema.Attribute{ + Optional: true, + Type: types.DynamicType, + }, + }, + }, + TerraformValue: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "dynamic": tftypes.DynamicPseudoType, + }, + }, + map[string]tftypes.Value{ + "dynamic": tftypes.NewValue(tftypes.String, nil), // Terraform knows the type, but the underlying value is null + }, + ), + }, + target: new(struct { + Dynamic types.Dynamic `tfsdk:"dynamic"` + }), + expected: &struct { + Dynamic types.Dynamic `tfsdk:"dynamic"` + }{ + Dynamic: types.DynamicValue(types.StringNull()), + }, + }, + "DynamicType-types.dynamic-unknown": { + data: fwschemadata.Data{ + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "dynamic": testschema.Attribute{ + Optional: true, + Type: types.DynamicType, + }, + }, + }, + TerraformValue: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "dynamic": tftypes.DynamicPseudoType, + }, + }, + map[string]tftypes.Value{ + "dynamic": tftypes.NewValue(tftypes.DynamicPseudoType, tftypes.UnknownValue), + }, + ), + }, + target: new(struct { + Dynamic types.Dynamic `tfsdk:"dynamic"` + }), + expected: &struct { + Dynamic types.Dynamic `tfsdk:"dynamic"` + }{ + Dynamic: types.DynamicUnknown(), + }, + }, + "DynamicType-types.dynamic-underlying-value-unknown": { + data: fwschemadata.Data{ + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "dynamic": testschema.Attribute{ + Optional: true, + Type: types.DynamicType, + }, + }, + }, + TerraformValue: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "dynamic": tftypes.DynamicPseudoType, + }, + }, + map[string]tftypes.Value{ + "dynamic": tftypes.NewValue(tftypes.Bool, tftypes.UnknownValue), // Terraform knows the type, but the underlying value is unknown + }, + ), + }, + target: new(struct { + Dynamic types.Dynamic `tfsdk:"dynamic"` + }), + expected: &struct { + Dynamic types.Dynamic `tfsdk:"dynamic"` + }{ + Dynamic: types.DynamicValue(types.BoolUnknown()), + }, + }, + "DynamicType-types.dynamic-stringvalue": { + data: fwschemadata.Data{ + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "string": testschema.Attribute{ + Optional: true, + Type: types.DynamicType, + }, + }, + }, + TerraformValue: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "string": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "string": tftypes.NewValue(tftypes.String, "test"), + }, + ), + }, + target: new(struct { + String types.Dynamic `tfsdk:"string"` + }), + expected: &struct { + String types.Dynamic `tfsdk:"string"` + }{ + String: types.DynamicValue(types.StringValue("test")), + }, + }, + "DynamicType-types.dynamic-listvalue": { + data: fwschemadata.Data{ + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "list": testschema.Attribute{ + Optional: true, + Type: types.DynamicType, + }, + }, + }, + TerraformValue: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "list": tftypes.List{ + ElementType: tftypes.String, + }, + }, + }, + map[string]tftypes.Value{ + "list": tftypes.NewValue( + tftypes.List{ + ElementType: tftypes.String, + }, + []tftypes.Value{ + tftypes.NewValue(tftypes.String, "test1"), + tftypes.NewValue(tftypes.String, "test2"), + }, + ), + }, + ), + }, + target: new(struct { + List types.Dynamic `tfsdk:"list"` + }), + expected: &struct { + List types.Dynamic `tfsdk:"list"` + }{ + List: types.DynamicValue(types.ListValueMust( + types.StringType, + []attr.Value{ + types.StringValue("test1"), + types.StringValue("test2"), + }, + )), + }, + }, + "DynamicType-types.dynamic-mapvalue": { + data: fwschemadata.Data{ + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "map": testschema.Attribute{ + Optional: true, + Type: types.DynamicType, + }, + }, + }, + TerraformValue: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "map": tftypes.Map{ + ElementType: tftypes.String, + }, + }, + }, + map[string]tftypes.Value{ + "map": tftypes.NewValue( + tftypes.Map{ + ElementType: tftypes.String, + }, + map[string]tftypes.Value{ + "key1": tftypes.NewValue(tftypes.String, "value1"), + "key2": tftypes.NewValue(tftypes.String, "value2"), + }, + ), + }, + ), + }, + target: new(struct { + Map types.Dynamic `tfsdk:"map"` + }), + expected: &struct { + Map types.Dynamic `tfsdk:"map"` + }{ + Map: types.DynamicValue(types.MapValueMust( + types.StringType, + map[string]attr.Value{ + "key1": types.StringValue("value1"), + "key2": types.StringValue("value2"), + }, + )), + }, + }, + "DynamicType-types.dynamic-objectvalue": { + data: fwschemadata.Data{ + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "object": testschema.Attribute{ + Optional: true, + Type: types.DynamicType, + }, + }, + }, + TerraformValue: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "object": tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "nested_string": tftypes.String, + }, + }, + }, + }, + map[string]tftypes.Value{ + "object": tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "nested_string": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "nested_string": tftypes.NewValue(tftypes.String, "test1"), + }, + ), + }, + ), + }, + target: new(struct { + Object types.Dynamic `tfsdk:"object"` + }), + expected: &struct { + Object types.Dynamic `tfsdk:"object"` + }{ + Object: types.DynamicValue(types.ObjectValueMust( + map[string]attr.Type{ + "nested_string": types.StringType, + }, + map[string]attr.Value{ + "nested_string": types.StringValue("test1"), + }, + )), + }, + }, + "DynamicType-types.dynamic-setvalue": { + data: fwschemadata.Data{ + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "set": testschema.Attribute{ + Optional: true, + Type: types.DynamicType, + }, + }, + }, + TerraformValue: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "set": tftypes.Set{ + ElementType: tftypes.String, + }, + }, + }, + map[string]tftypes.Value{ + "set": tftypes.NewValue( + tftypes.Set{ + ElementType: tftypes.String, + }, + []tftypes.Value{ + tftypes.NewValue(tftypes.String, "test1"), + tftypes.NewValue(tftypes.String, "test2"), + }, + ), + }, + ), + }, + target: new(struct { + Set types.Dynamic `tfsdk:"set"` + }), + expected: &struct { + Set types.Dynamic `tfsdk:"set"` + }{ + Set: types.DynamicValue(types.SetValueMust( + types.StringType, + []attr.Value{ + types.StringValue("test1"), + types.StringValue("test2"), + }, + )), + }, + }, } for name, tc := range testCases { diff --git a/internal/fwschemadata/data_nullify_collection_blocks.go b/internal/fwschemadata/data_nullify_collection_blocks.go index e1a1032c4..f907d6d16 100644 --- a/internal/fwschemadata/data_nullify_collection_blocks.go +++ b/internal/fwschemadata/data_nullify_collection_blocks.go @@ -5,6 +5,7 @@ package fwschemadata import ( "context" + "errors" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/internal/fromtftypes" @@ -22,11 +23,25 @@ func (d *Data) NullifyCollectionBlocks(ctx context.Context) diag.Diagnostics { // Errors are handled as richer diag.Diagnostics instead. d.TerraformValue, _ = tftypes.Transform(d.TerraformValue, func(tfTypePath *tftypes.AttributePath, tfTypeValue tftypes.Value) (tftypes.Value, error) { + // Skip the root of the data + if len(tfTypePath.Steps()) < 1 { + return tfTypeValue, nil + } + // Do not transform if value is already null or is not fully known. if tfTypeValue.IsNull() || !tfTypeValue.IsFullyKnown() { return tfTypeValue, nil } + _, err := d.Schema.AttributeAtTerraformPath(ctx, tfTypePath) + if err != nil { + if errors.Is(err, fwschema.ErrPathInsideDynamicAttribute) { + // ignore attributes/elements inside schema.DynamicAttribute + logging.FrameworkTrace(ctx, "attribute is inside of a dynamic attribute, skipping nullify collection blocks") + return tfTypeValue, nil + } + } + fwPath, fwPathDiags := fromtftypes.AttributePath(ctx, tfTypePath, d.Schema) diags.Append(fwPathDiags...) diff --git a/internal/fwschemadata/data_nullify_collection_blocks_test.go b/internal/fwschemadata/data_nullify_collection_blocks_test.go index 71222d492..9eb2a4c9c 100644 --- a/internal/fwschemadata/data_nullify_collection_blocks_test.go +++ b/internal/fwschemadata/data_nullify_collection_blocks_test.go @@ -888,6 +888,65 @@ func TestDataNullifyCollectionBlocks(t *testing.T) { ), }, }, + // Dynamic attributes that contain underlying list values should be skipped + "dynamic-attribute-with-list-unmodified": { + data: &fwschemadata.Data{ + Description: fwschemadata.DataDescriptionConfiguration, + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "dynamic_attribute": testschema.Attribute{ + Optional: true, + Type: types.DynamicType, + }, + }, + }, + TerraformValue: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "dynamic_attribute": tftypes.DynamicPseudoType, + }, + }, + map[string]tftypes.Value{ + "dynamic_attribute": tftypes.NewValue( + tftypes.List{ + ElementType: tftypes.String, + }, + []tftypes.Value{ + tftypes.NewValue(tftypes.String, "hello"), + }, + ), + }, + ), + }, + expected: &fwschemadata.Data{ + Description: fwschemadata.DataDescriptionConfiguration, + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "dynamic_attribute": testschema.Attribute{ + Optional: true, + Type: types.DynamicType, + }, + }, + }, + TerraformValue: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "dynamic_attribute": tftypes.DynamicPseudoType, + }, + }, + map[string]tftypes.Value{ + "dynamic_attribute": tftypes.NewValue( + tftypes.List{ + ElementType: tftypes.String, + }, + []tftypes.Value{ + tftypes.NewValue(tftypes.String, "hello"), + }, + ), + }, + ), + }, + }, } for name, testCase := range testCases { diff --git a/internal/fwschemadata/data_path_exists_test.go b/internal/fwschemadata/data_path_exists_test.go index 962e14871..fd67033f6 100644 --- a/internal/fwschemadata/data_path_exists_test.go +++ b/internal/fwschemadata/data_path_exists_test.go @@ -430,6 +430,372 @@ func TestDataPathExists(t *testing.T) { path: path.Root("test").AtSetValue(types.StringValue("othervalue")), expected: false, }, + // This is the expected correct path to access a dynamic attribute (at the root only) + "DynamicType-WithAttributeName": { + data: fwschemadata.Data{ + TerraformValue: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.DynamicPseudoType, + }, + }, map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "testvalue"), + }), + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "test": testschema.Attribute{ + Type: types.DynamicType, + Required: true, + }, + }, + }, + }, + path: path.Root("test"), + expected: true, + }, + "DynamicType-WithAttributeName-mismatch": { + data: fwschemadata.Data{ + TerraformValue: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.DynamicPseudoType, + }, + }, map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "testvalue"), + }), + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "test": testschema.Attribute{ + Type: types.DynamicType, + Required: true, + }, + }, + }, + }, + path: path.Root("other"), + expected: false, + }, + // This test passes because the underlying `(Data).PathExists` function uses the TerraformValue and not the Schema. + // Framework dynamic attributes don't allow you to step into them with paths. + "DynamicType-WithAttributeName.WithAttributeName": { + data: fwschemadata.Data{ + TerraformValue: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.DynamicPseudoType, + }, + }, map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "nested": tftypes.String, + }, + }, map[string]tftypes.Value{ + "nested": tftypes.NewValue(tftypes.String, "testvalue"), + }), + }), + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "test": testschema.Attribute{ + Type: types.DynamicType, + Required: true, + }, + }, + }, + }, + path: path.Root("test").AtName("nested"), + expected: true, + }, + "DynamicType-WithAttributeName.WithAttributeName-mismatch-child": { + data: fwschemadata.Data{ + TerraformValue: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.DynamicPseudoType, + }, + }, map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "nested": tftypes.String, + }, + }, map[string]tftypes.Value{ + "nested": tftypes.NewValue(tftypes.String, "testvalue"), + }), + }), + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "test": testschema.Attribute{ + Type: types.DynamicType, + Required: true, + }, + }, + }, + }, + path: path.Root("test").AtName("other"), + expected: false, + }, + "DynamicType-WithAttributeName.WithAttributeName-mismatch-parent": { + data: fwschemadata.Data{ + TerraformValue: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.DynamicPseudoType, + }, + }, map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "testvalue"), + }), + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "test": testschema.Attribute{ + Type: types.DynamicType, + Required: true, + }, + }, + }, + }, + path: path.Root("test").AtName("other"), + expected: false, + }, + // This test passes because the underlying `(Data).PathExists` function uses the TerraformValue and not the Schema. + // Framework dynamic attributes don't allow you to step into them with paths. + "DynamicType-WithAttributeName.WithElementKeyInt": { + data: fwschemadata.Data{ + TerraformValue: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.DynamicPseudoType, + }, + }, map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.List{ + ElementType: tftypes.String, + }, []tftypes.Value{ + tftypes.NewValue(tftypes.String, "testvalue"), + }), + }), + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "test": testschema.Attribute{ + Type: types.DynamicType, + Required: true, + }, + }, + }, + }, + path: path.Root("test").AtListIndex(0), + expected: true, + }, + "DynamicType-WithAttributeName.WithElementKeyInt-mismatch-child": { + data: fwschemadata.Data{ + TerraformValue: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.DynamicPseudoType, + }, + }, map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.List{ + ElementType: tftypes.String, + }, []tftypes.Value{ + tftypes.NewValue(tftypes.String, "testvalue"), + }), + }), + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "test": testschema.Attribute{ + Type: types.DynamicType, + Required: true, + }, + }, + }, + }, + path: path.Root("test").AtListIndex(1), + expected: false, + }, + "DynamicType-WithAttributeName.WithElementKeyInt-mismatch-parent": { + data: fwschemadata.Data{ + TerraformValue: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.DynamicPseudoType, + }, + }, map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "testvalue"), + }), + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "test": testschema.Attribute{ + Type: types.DynamicType, + Required: true, + }, + }, + }, + }, + path: path.Root("test").AtListIndex(0), + expected: false, + }, + // This test passes because the underlying `(Data).PathExists` function uses the TerraformValue and not the Schema. + // Framework dynamic attributes don't allow you to step into them with paths. + "DynamicType-WithAttributeName.WithElementKeyString": { + data: fwschemadata.Data{ + TerraformValue: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.DynamicPseudoType, + }, + }, map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.Map{ + ElementType: tftypes.String, + }, map[string]tftypes.Value{ + "key": tftypes.NewValue(tftypes.String, "testvalue"), + }), + }), + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "test": testschema.Attribute{ + Type: types.DynamicType, + Required: true, + }, + }, + }, + }, + path: path.Root("test").AtMapKey("key"), + expected: true, + }, + "DynamicType-WithAttributeName.WithElementKeyString-mismatch-child": { + data: fwschemadata.Data{ + TerraformValue: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.DynamicPseudoType, + }, + }, map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.Map{ + ElementType: tftypes.String, + }, map[string]tftypes.Value{ + "key": tftypes.NewValue(tftypes.String, "testvalue"), + }), + }), + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "test": testschema.Attribute{ + Type: types.DynamicType, + Required: true, + }, + }, + }, + }, + path: path.Root("test").AtMapKey("other"), + expected: false, + }, + "DynamicType-WithAttributeName.WithElementKeyString-mismatch-parent": { + data: fwschemadata.Data{ + TerraformValue: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.DynamicPseudoType, + }, + }, map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "testvalue"), + }), + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "test": testschema.Attribute{ + Type: types.DynamicType, + Required: true, + }, + }, + }, + }, + path: path.Root("test").AtMapKey("other"), + expected: false, + }, + // This test passes because the underlying `(Data).PathExists` function uses the TerraformValue and not the Schema. + // Framework dynamic attributes don't allow you to step into them with paths. + "DynamicType-WithAttributeName.WithElementKeyValue-StringValue": { + data: fwschemadata.Data{ + TerraformValue: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.DynamicPseudoType, + }, + }, map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.Set{ + ElementType: tftypes.String, + }, []tftypes.Value{ + tftypes.NewValue(tftypes.String, "testvalue"), + }), + }), + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "test": testschema.Attribute{ + Type: types.DynamicType, + Required: true, + }, + }, + }, + }, + path: path.Root("test").AtSetValue(types.StringValue("testvalue")), + expected: true, + }, + // This test passes because the underlying `(Data).PathExists` function uses the TerraformValue and not the Schema. + // Framework dynamic attributes don't allow you to step into them with paths. + "DynamicType-WithAttributeName.WithElementKeyValue-DynamicValue": { + data: fwschemadata.Data{ + TerraformValue: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.DynamicPseudoType, + }, + }, map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.Set{ + ElementType: tftypes.String, + }, []tftypes.Value{ + tftypes.NewValue(tftypes.String, "testvalue"), + }), + }), + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "test": testschema.Attribute{ + Type: types.DynamicType, + Required: true, + }, + }, + }, + }, + path: path.Root("test").AtSetValue(types.DynamicValue(types.StringValue("testvalue"))), + expected: true, + }, + "DynamicType-WithAttributeName.WithElementKeyValue-mismatch-child": { + data: fwschemadata.Data{ + TerraformValue: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.DynamicPseudoType, + }, + }, map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.Set{ + ElementType: tftypes.String, + }, []tftypes.Value{ + tftypes.NewValue(tftypes.String, "testvalue"), + }), + }), + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "test": testschema.Attribute{ + Type: types.DynamicType, + Required: true, + }, + }, + }, + }, + path: path.Root("test").AtSetValue(types.StringValue("othervalue")), + expected: false, + }, + "DynamicType-WithAttributeName.WithElementKeyValue-mismatch-parent": { + data: fwschemadata.Data{ + TerraformValue: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.DynamicPseudoType, + }, + }, map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "testvalue"), + }), + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "test": testschema.Attribute{ + Type: types.DynamicType, + Required: true, + }, + }, + }, + }, + path: path.Root("test").AtSetValue(types.StringValue("othervalue")), + expected: false, + }, } for name, tc := range testCases { diff --git a/internal/fwschemadata/data_path_matches_test.go b/internal/fwschemadata/data_path_matches_test.go index e782023dc..abbe5be3a 100644 --- a/internal/fwschemadata/data_path_matches_test.go +++ b/internal/fwschemadata/data_path_matches_test.go @@ -1373,6 +1373,99 @@ func TestDataPathMatches(t *testing.T) { ), }, }, + "AttributeNameExact-match-dynamic-attribute": { + schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "test": testschema.Attribute{ + Type: types.DynamicType, + }, + }, + }, + tfTypeValue: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.DynamicPseudoType, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + expression: path.MatchRoot("test"), + expected: path.Paths{ + path.Root("test"), + }, + }, + "AttributeNameExact-mismatch-dynamic-attribute": { + schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "test": testschema.Attribute{ + Type: types.DynamicType, + }, + }, + }, + tfTypeValue: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.DynamicPseudoType, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + expression: path.MatchRoot("not-test"), + expected: nil, + expectedDiags: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Path Expression for Schema", + "The Terraform Provider unexpectedly provided a path expression that does not match the current schema. "+ + "This can happen if the path expression does not correctly follow the schema in structure or types. "+ + "Please report this to the provider developers.\n\n"+ + "Path Expression: not-test", + ), + }, + }, + // Path match expressions that step into dynamic attributes are not valid + "AttributeNameExact-ElementKeyIntAny-invalid-on-dynamic-attribute": { + schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "test": testschema.Attribute{ + Type: types.DynamicType, + }, + }, + }, + tfTypeValue: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.DynamicPseudoType, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue( + tftypes.List{ + ElementType: tftypes.String, + }, + []tftypes.Value{ + tftypes.NewValue(tftypes.String, "test-value1"), + tftypes.NewValue(tftypes.String, "test-value2"), + tftypes.NewValue(tftypes.String, "test-value3"), + }, + ), + }, + ), + expression: path.MatchRoot("test").AtAnyListIndex(), + expected: nil, + expectedDiags: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Path Expression for Schema", + "The Terraform Provider unexpectedly provided a path expression that does not match the current schema. "+ + "This can happen if the path expression does not correctly follow the schema in structure or types. "+ + "Please report this to the provider developers.\n\n"+ + "Path Expression: test[*]", + ), + }, + }, } for name, testCase := range testCases { diff --git a/internal/fwschemadata/data_reify_null_collection_blocks.go b/internal/fwschemadata/data_reify_null_collection_blocks.go index 933a6c9de..64d10bd64 100644 --- a/internal/fwschemadata/data_reify_null_collection_blocks.go +++ b/internal/fwschemadata/data_reify_null_collection_blocks.go @@ -5,6 +5,7 @@ package fwschemadata import ( "context" + "errors" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/internal/fromtftypes" @@ -22,11 +23,25 @@ func (d *Data) ReifyNullCollectionBlocks(ctx context.Context) diag.Diagnostics { // Errors are handled as richer diag.Diagnostics instead. d.TerraformValue, _ = tftypes.Transform(d.TerraformValue, func(tfTypePath *tftypes.AttributePath, tfTypeValue tftypes.Value) (tftypes.Value, error) { + // Skip the root of the data + if len(tfTypePath.Steps()) < 1 { + return tfTypeValue, nil + } + // Only transform null values. if !tfTypeValue.IsNull() { return tfTypeValue, nil } + _, err := d.Schema.AttributeAtTerraformPath(ctx, tfTypePath) + if err != nil { + if errors.Is(err, fwschema.ErrPathInsideDynamicAttribute) { + // ignore attributes/elements inside schema.DynamicAttribute + logging.FrameworkTrace(ctx, "attribute is inside of a dynamic attribute, skipping reify null collection blocks") + return tfTypeValue, nil + } + } + fwPath, fwPathDiags := fromtftypes.AttributePath(ctx, tfTypePath, d.Schema) diags.Append(fwPathDiags...) diff --git a/internal/fwschemadata/data_reify_null_collection_blocks_test.go b/internal/fwschemadata/data_reify_null_collection_blocks_test.go index 1ad24a003..1e7161a2f 100644 --- a/internal/fwschemadata/data_reify_null_collection_blocks_test.go +++ b/internal/fwschemadata/data_reify_null_collection_blocks_test.go @@ -1132,6 +1132,65 @@ func TestDataReifyNullCollectionBlocks(t *testing.T) { ), }, }, + // Dynamic attributes that contain underlying list values should be skipped + "dynamic-attribute-with-list-unmodified": { + data: &fwschemadata.Data{ + Description: fwschemadata.DataDescriptionConfiguration, + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "dynamic_attribute": testschema.Attribute{ + Optional: true, + Type: types.DynamicType, + }, + }, + }, + TerraformValue: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "dynamic_attribute": tftypes.DynamicPseudoType, + }, + }, + map[string]tftypes.Value{ + "dynamic_attribute": tftypes.NewValue( + tftypes.List{ + ElementType: tftypes.String, + }, + []tftypes.Value{ + tftypes.NewValue(tftypes.String, "hello"), + }, + ), + }, + ), + }, + expected: &fwschemadata.Data{ + Description: fwschemadata.DataDescriptionConfiguration, + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "dynamic_attribute": testschema.Attribute{ + Optional: true, + Type: types.DynamicType, + }, + }, + }, + TerraformValue: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "dynamic_attribute": tftypes.DynamicPseudoType, + }, + }, + map[string]tftypes.Value{ + "dynamic_attribute": tftypes.NewValue( + tftypes.List{ + ElementType: tftypes.String, + }, + []tftypes.Value{ + tftypes.NewValue(tftypes.String, "hello"), + }, + ), + }, + ), + }, + }, } for name, testCase := range testCases { diff --git a/internal/fwschemadata/data_set_at_path.go b/internal/fwschemadata/data_set_at_path.go index c7febc8c7..532cb2710 100644 --- a/internal/fwschemadata/data_set_at_path.go +++ b/internal/fwschemadata/data_set_at_path.go @@ -158,10 +158,6 @@ func (d Data) SetAtPathTransformFunc(ctx context.Context, path path.Path, tfVal } if parentValue.IsNull() || !parentValue.IsKnown() { - // TODO: This will break when DynamicPsuedoType is introduced. - // tftypes.Type should implement AttributePathStepper, but it currently does not. - // When it does, we should use: tftypes.WalkAttributePath(p.Raw.Type(), parentPath) - // Reference: https://github.com/hashicorp/terraform-plugin-go/issues/110 parentType := parentAttrType.TerraformType(ctx) var childValue interface{} diff --git a/internal/fwschemadata/data_set_at_path_test.go b/internal/fwschemadata/data_set_at_path_test.go index 01638c665..2d783785a 100644 --- a/internal/fwschemadata/data_set_at_path_test.go +++ b/internal/fwschemadata/data_set_at_path_test.go @@ -1509,6 +1509,42 @@ func TestDataSetAtPath(t *testing.T) { "other": tftypes.NewValue(tftypes.String, "should be untouched"), }), }, + "overwrite-Dynamic": { + data: fwschemadata.Data{ + TerraformValue: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + "other": tftypes.String, + }, + }, map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "originalvalue"), + "other": tftypes.NewValue(tftypes.String, "should be untouched"), + }), + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "test": testschema.Attribute{ + Type: types.DynamicType, + Required: true, + }, + "other": testschema.Attribute{ + Type: types.StringType, + Required: true, + }, + }, + }, + }, + path: path.Root("test"), + val: "newvalue", + expected: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + "other": tftypes.String, + }, + }, map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "newvalue"), + "other": tftypes.NewValue(tftypes.String, "should be untouched"), + }), + }, "write-root": { data: fwschemadata.Data{ TerraformValue: tftypes.NewValue(tftypes.Object{ @@ -2482,6 +2518,39 @@ func TestDataSetAtPath(t *testing.T) { "other": tftypes.NewValue(tftypes.String, nil), }), }, + "write-Dynamic": { + data: fwschemadata.Data{ + TerraformValue: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.DynamicPseudoType, + "other": tftypes.DynamicPseudoType, + }, + }, nil), + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "test": testschema.Attribute{ + Type: types.DynamicType, + Required: true, + }, + "other": testschema.Attribute{ + Type: types.DynamicType, + Required: true, + }, + }, + }, + }, + path: path.Root("test"), + val: "newvalue", + expected: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.DynamicPseudoType, + "other": tftypes.DynamicPseudoType, + }, + }, map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "newvalue"), + "other": tftypes.NewValue(tftypes.DynamicPseudoType, nil), + }), + }, "AttrTypeWithValidateError": { data: fwschemadata.Data{ TerraformValue: tftypes.NewValue(tftypes.Object{ diff --git a/internal/fwschemadata/data_set_test.go b/internal/fwschemadata/data_set_test.go index 9711450f0..ce827faf9 100644 --- a/internal/fwschemadata/data_set_test.go +++ b/internal/fwschemadata/data_set_test.go @@ -8,6 +8,7 @@ import ( "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/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/fwschemadata" @@ -60,6 +61,84 @@ func TestDataSet(t *testing.T) { "name": tftypes.NewValue(tftypes.String, "newvalue"), }), }, + "write-dynamic": { + data: fwschemadata.Data{ + TerraformValue: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "dyn_attr": tftypes.DynamicPseudoType, + }, + }, map[string]tftypes.Value{ + "dyn_attr": tftypes.NewValue(tftypes.String, "oldvalue"), + }), + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "dyn_attr": testschema.Attribute{ + Type: types.DynamicType, + Required: true, + }, + }, + }, + }, + val: struct { + DynAttr types.Dynamic `tfsdk:"dyn_attr"` + }{ + DynAttr: types.DynamicValue(types.StringValue("newvalue")), + }, + expected: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "dyn_attr": tftypes.DynamicPseudoType, + }, + }, map[string]tftypes.Value{ + "dyn_attr": tftypes.NewValue(tftypes.String, "newvalue"), + }), + }, + "write-dynamic-different-types": { + data: fwschemadata.Data{ + TerraformValue: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "dyn_attr": tftypes.DynamicPseudoType, + }, + }, map[string]tftypes.Value{ + "dyn_attr": tftypes.NewValue(tftypes.String, "oldvalue"), + }), + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "dyn_attr": testschema.Attribute{ + Type: types.DynamicType, + Required: true, + }, + }, + }, + }, + val: struct { + DynAttr types.Dynamic `tfsdk:"dyn_attr"` + }{ + DynAttr: types.DynamicValue( + types.SetValueMust( + types.Float64Type, + []attr.Value{ + types.Float64Value(1.23), + types.Float64Value(4.56), + }, + ), + ), + }, + expected: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "dyn_attr": tftypes.DynamicPseudoType, + }, + }, map[string]tftypes.Value{ + "dyn_attr": tftypes.NewValue( + tftypes.Set{ + ElementType: tftypes.Number, + }, + []tftypes.Value{ + tftypes.NewValue(tftypes.Number, 1.23), + tftypes.NewValue(tftypes.Number, 4.56), + }, + ), + }), + }, "overwrite": { data: fwschemadata.Data{ TerraformValue: tftypes.Value{}, @@ -85,6 +164,31 @@ func TestDataSet(t *testing.T) { "name": tftypes.NewValue(tftypes.String, "newvalue"), }), }, + "overwrite-dynamic": { + data: fwschemadata.Data{ + TerraformValue: tftypes.Value{}, + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "dyn_attr": testschema.Attribute{ + Type: types.DynamicType, + Required: true, + }, + }, + }, + }, + val: struct { + DynAttr types.Dynamic `tfsdk:"dyn_attr"` + }{ + DynAttr: types.DynamicValue(types.BoolValue(true)), + }, + expected: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "dyn_attr": tftypes.DynamicPseudoType, + }, + }, map[string]tftypes.Value{ + "dyn_attr": tftypes.NewValue(tftypes.Bool, true), + }), + }, "multiple-attributes": { data: fwschemadata.Data{ TerraformValue: tftypes.Value{}, diff --git a/internal/fwschemadata/data_valid_path_expression_test.go b/internal/fwschemadata/data_valid_path_expression_test.go index f5defd631..decef1271 100644 --- a/internal/fwschemadata/data_valid_path_expression_test.go +++ b/internal/fwschemadata/data_valid_path_expression_test.go @@ -252,6 +252,35 @@ func TestDataValidPathExpression(t *testing.T) { expression: path.MatchRoot("test").AtSetValue(types.StringValue("test-value")), expected: false, }, + "AttributeNameExact-match-dynamic-attribute": { + data: fwschemadata.Data{ + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "test": testschema.Attribute{ + Required: true, + Type: types.DynamicType, + }, + }, + }, + }, + expression: path.MatchRoot("test"), + expected: true, + }, + // Stepping into a dynamic attribute will return an error, which will result in a mismatch + "AttributeNameExact-mismatch-dynamic-attribute-invalid-step": { + data: fwschemadata.Data{ + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "test": testschema.Attribute{ + Required: true, + Type: types.DynamicType, + }, + }, + }, + }, + expression: path.MatchRoot("test").AtListIndex(0), + expected: false, + }, } for name, testCase := range testCases { diff --git a/internal/fwschemadata/data_value_test.go b/internal/fwschemadata/data_value_test.go index ea6090b75..0bc1dfeed 100644 --- a/internal/fwschemadata/data_value_test.go +++ b/internal/fwschemadata/data_value_test.go @@ -1718,6 +1718,318 @@ func TestDataValueAtPath(t *testing.T) { path: path.Root("test"), expected: types.StringValue("value"), }, + "WithAttributeName-Dynamic-null": { + data: fwschemadata.Data{ + TerraformValue: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.DynamicPseudoType, + "other": tftypes.Bool, + }, + }, map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.DynamicPseudoType, nil), + "other": tftypes.NewValue(tftypes.Bool, nil), + }), + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "test": testschema.Attribute{ + Type: types.DynamicType, + Required: true, + }, + "other": testschema.Attribute{ + Type: types.StringType, + Required: true, + }, + }, + }, + }, + path: path.Root("test"), + expected: types.DynamicNull(), + }, + "WithAttributeName-Dynamic-underlying-value-null": { + data: fwschemadata.Data{ + TerraformValue: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.DynamicPseudoType, + "other": tftypes.Bool, + }, + }, map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, nil), // A concrete type! :O + "other": tftypes.NewValue(tftypes.Bool, nil), + }), + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "test": testschema.Attribute{ + Type: types.DynamicType, + Required: true, + }, + "other": testschema.Attribute{ + Type: types.StringType, + Required: true, + }, + }, + }, + }, + path: path.Root("test"), + expected: types.DynamicValue(types.StringNull()), + }, + "WithAttributeName-Dynamic-unknown": { + data: fwschemadata.Data{ + TerraformValue: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.DynamicPseudoType, + "other": tftypes.Bool, + }, + }, map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.DynamicPseudoType, tftypes.UnknownValue), + "other": tftypes.NewValue(tftypes.Bool, nil), + }), + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "test": testschema.Attribute{ + Type: types.DynamicType, + Required: true, + }, + "other": testschema.Attribute{ + Type: types.BoolType, + Optional: true, + }, + }, + }, + }, + path: path.Root("test"), + expected: types.DynamicUnknown(), + }, + "WithAttributeName-Dynamic-underlying-value-unknown": { + data: fwschemadata.Data{ + TerraformValue: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.DynamicPseudoType, + "other": tftypes.Bool, + }, + }, map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.Number, tftypes.UnknownValue), // A concrete type! :O + "other": tftypes.NewValue(tftypes.Bool, nil), + }), + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "test": testschema.Attribute{ + Type: types.DynamicType, + Required: true, + }, + "other": testschema.Attribute{ + Type: types.BoolType, + Optional: true, + }, + }, + }, + }, + path: path.Root("test"), + expected: types.DynamicValue(types.NumberUnknown()), + }, + "WithAttributeName-Dynamic-value": { + data: fwschemadata.Data{ + TerraformValue: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + "other": tftypes.Bool, + }, + }, map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "value"), + "other": tftypes.NewValue(tftypes.Bool, nil), + }), + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "test": testschema.Attribute{ + Type: types.DynamicType, + Required: true, + }, + "other": testschema.Attribute{ + Type: types.BoolType, + Optional: true, + }, + }, + }, + }, + path: path.Root("test"), + expected: types.DynamicValue(types.StringValue("value")), + }, + // MAINTAINER NOTE: Paths currently cannot target values inside of dynamic types, even if the underlying data matches the path. + // If we enable this functionality in the future, this test should be updated to correctly grab the data. + "WithAttributeName-Dynamic-List-WithElementKeyInt-Error": { + data: fwschemadata.Data{ + TerraformValue: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.List{ + ElementType: tftypes.String, + }, + "other": tftypes.Bool, + }, + }, map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.List{ + ElementType: tftypes.String, + }, []tftypes.Value{ + tftypes.NewValue(tftypes.String, "value"), + tftypes.NewValue(tftypes.String, "othervalue"), + }), + "other": tftypes.NewValue(tftypes.Bool, nil), + }), + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "test": testschema.Attribute{ + Type: types.DynamicType, + Required: true, + }, + "other": testschema.Attribute{ + Type: types.BoolType, + Optional: true, + }, + }, + }, + }, + path: path.Root("test").AtListIndex(0), + expected: nil, + expectedDiags: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtListIndex(0), + "Data Read Error", + "An unexpected error was encountered trying to retrieve type information at a given path. This is always an error in the provider. Please report the following to the provider developer:\n\n"+ + "Error: path leads to element or attribute nested in a schema.DynamicAttribute", + ), + }, + }, + // MAINTAINER NOTE: Paths currently cannot target values inside of dynamic types, even if the underlying data matches the path. + // If we enable this functionality in the future, this test should be updated to correctly grab the data. + "WithAttributeName-Dynamic-Map-WithElementKeyString-Error": { + data: fwschemadata.Data{ + TerraformValue: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.Map{ + ElementType: tftypes.String, + }, + "other": tftypes.Bool, + }, + }, map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.Map{ + ElementType: tftypes.String, + }, map[string]tftypes.Value{ + "sub_test": tftypes.NewValue(tftypes.String, "value"), + "other": tftypes.NewValue(tftypes.String, "othervalue"), + }), + "other": tftypes.NewValue(tftypes.Bool, nil), + }), + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "test": testschema.Attribute{ + Type: types.DynamicType, + Required: true, + }, + "other": testschema.Attribute{ + Type: types.BoolType, + Optional: true, + }, + }, + }, + }, + path: path.Root("test").AtMapKey("sub_test"), + expected: nil, + expectedDiags: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtMapKey("sub_test"), + "Data Read Error", + "An unexpected error was encountered trying to retrieve type information at a given path. This is always an error in the provider. Please report the following to the provider developer:\n\n"+ + "Error: path leads to element or attribute nested in a schema.DynamicAttribute", + ), + }, + }, + // MAINTAINER NOTE: Paths currently cannot target values inside of dynamic types, even if the underlying data matches the path. + // If we enable this functionality in the future, this test should be updated to correctly grab the data. + "WithAttributeName-Dynamic-Set-WithElementKeyValue-At-DynamicValue-Error": { + data: fwschemadata.Data{ + TerraformValue: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.DynamicPseudoType, + "other": tftypes.Bool, + }, + }, map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.Set{ + ElementType: tftypes.String, + }, []tftypes.Value{ + tftypes.NewValue(tftypes.String, "value"), + tftypes.NewValue(tftypes.String, "othervalue"), + }), + "other": tftypes.NewValue(tftypes.Bool, nil), + }), + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "test": testschema.Attribute{ + Type: types.DynamicType, + Required: true, + }, + "other": testschema.Attribute{ + Type: types.BoolType, + Optional: true, + }, + }, + }, + }, + path: path.Root("test").AtSetValue(types.DynamicValue(types.StringValue("value"))), + expected: nil, + expectedDiags: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtSetValue(types.DynamicValue(types.StringValue("value"))), + "Data Read Error", + "An unexpected error was encountered trying to retrieve type information at a given path. This is always an error in the provider. Please report the following to the provider developer:\n\n"+ + "Error: path leads to element or attribute nested in a schema.DynamicAttribute", + ), + }, + }, + // MAINTAINER NOTE: Paths currently cannot target values inside of dynamic types, even if the underlying data matches the path. + // If we enable this functionality in the future, this test should be updated to correctly grab the data. + "WithAttributeName-Dynamic-Object-WithAttributeName-Error": { + data: fwschemadata.Data{ + TerraformValue: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "sub_test": tftypes.String, + }, + }, + "other": tftypes.Bool, + }, + }, map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "sub_test": tftypes.String, + }, + }, map[string]tftypes.Value{ + "sub_test": tftypes.NewValue(tftypes.String, "value"), + }), + "other": tftypes.NewValue(tftypes.Bool, nil), + }), + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "test": testschema.Attribute{ + Type: types.DynamicType, + Required: true, + }, + "other": testschema.Attribute{ + Type: types.BoolType, + Optional: true, + }, + }, + }, + }, + path: path.Root("test").AtName("sub_test"), + expected: nil, + expectedDiags: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test").AtName("sub_test"), + "Data Read Error", + "An unexpected error was encountered trying to retrieve type information at a given path. This is always an error in the provider. Please report the following to the provider developer:\n\n"+ + "Error: path leads to element or attribute nested in a schema.DynamicAttribute", + ), + }, + }, "AttrTypeWithValidateError": { data: fwschemadata.Data{ TerraformValue: tftypes.NewValue(tftypes.Object{ diff --git a/internal/fwschemadata/value_semantic_equality.go b/internal/fwschemadata/value_semantic_equality.go index cc7fb6f85..e93ae8396 100644 --- a/internal/fwschemadata/value_semantic_equality.go +++ b/internal/fwschemadata/value_semantic_equality.go @@ -79,6 +79,8 @@ func ValueSemanticEquality(ctx context.Context, req ValueSemanticEqualityRequest ValueSemanticEqualitySet(ctx, req, resp) case basetypes.StringValuable: ValueSemanticEqualityString(ctx, req, resp) + case basetypes.DynamicValuable: + ValueSemanticEqualityDynamic(ctx, req, resp) } if resp.NewValue.Equal(req.PriorValue) { diff --git a/internal/fwschemadata/value_semantic_equality_dynamic.go b/internal/fwschemadata/value_semantic_equality_dynamic.go new file mode 100644 index 000000000..6a23cd1ee --- /dev/null +++ b/internal/fwschemadata/value_semantic_equality_dynamic.go @@ -0,0 +1,78 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package fwschemadata + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/internal/logging" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" +) + +// ValueSemanticEqualityDynamic performs dynamic type semantic equality. +func ValueSemanticEqualityDynamic(ctx context.Context, req ValueSemanticEqualityRequest, resp *ValueSemanticEqualityResponse) { + priorValuable, ok := req.PriorValue.(basetypes.DynamicValuableWithSemanticEquals) + + // No changes required if the interface is not implemented. + if !ok { + return + } + + proposedNewValuable, ok := req.ProposedNewValue.(basetypes.DynamicValuableWithSemanticEquals) + + // No changes required if the interface is not implemented. + if !ok { + return + } + + logging.FrameworkTrace( + ctx, + "Calling provider defined type-based SemanticEquals", + map[string]interface{}{ + logging.KeyValueType: proposedNewValuable.String(), + }, + ) + + // The prior dynamic value has alredy been checked for null or unknown, however, we also + // need to check the underlying value for null or unknown. + priorValue, diags := priorValuable.ToDynamicValue(ctx) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + if priorValue.IsUnderlyingValueNull() || priorValue.IsUnderlyingValueUnknown() { + return + } + + // The proposed new dynamic value has alredy been checked for null or unknown, however, we also + // need to check the underlying value for null or unknown. + proposedValue, diags := proposedNewValuable.ToDynamicValue(ctx) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + if proposedValue.IsUnderlyingValueNull() || proposedValue.IsUnderlyingValueUnknown() { + return + } + + usePriorValue, diags := proposedNewValuable.DynamicSemanticEquals(ctx, priorValuable) + + logging.FrameworkTrace( + ctx, + "Called provider defined type-based SemanticEquals", + map[string]interface{}{ + logging.KeyValueType: proposedNewValuable.String(), + }, + ) + + resp.Diagnostics.Append(diags...) + + if !usePriorValue { + return + } + + resp.NewValue = priorValuable +} diff --git a/internal/fwschemadata/value_semantic_equality_dynamic_test.go b/internal/fwschemadata/value_semantic_equality_dynamic_test.go new file mode 100644 index 000000000..183b893c1 --- /dev/null +++ b/internal/fwschemadata/value_semantic_equality_dynamic_test.go @@ -0,0 +1,203 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package fwschemadata_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschemadata" + "github.com/hashicorp/terraform-plugin-framework/internal/testing/testtypes" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func TestValueSemanticEqualityDynamic(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + request fwschemadata.ValueSemanticEqualityRequest + expected *fwschemadata.ValueSemanticEqualityResponse + }{ + "DynamicValue": { + request: fwschemadata.ValueSemanticEqualityRequest{ + Path: path.Root("test"), + PriorValue: types.DynamicValue(types.StringValue("prior")), + ProposedNewValue: types.DynamicValue(types.StringValue("new")), + }, + expected: &fwschemadata.ValueSemanticEqualityResponse{ + NewValue: types.DynamicValue(types.StringValue("new")), + }, + }, + "DynamicValuableWithSemanticEquals-true": { + request: fwschemadata.ValueSemanticEqualityRequest{ + Path: path.Root("test"), + PriorValue: testtypes.DynamicValueWithSemanticEquals{ + DynamicValue: types.DynamicValue(types.StringValue("prior")), + SemanticEquals: true, + }, + ProposedNewValue: testtypes.DynamicValueWithSemanticEquals{ + DynamicValue: types.DynamicValue(types.StringValue("new")), + SemanticEquals: true, + }, + }, + expected: &fwschemadata.ValueSemanticEqualityResponse{ + NewValue: testtypes.DynamicValueWithSemanticEquals{ + DynamicValue: types.DynamicValue(types.StringValue("prior")), + SemanticEquals: true, + }, + }, + }, + "DynamicValuableWithSemanticEquals-false": { + request: fwschemadata.ValueSemanticEqualityRequest{ + Path: path.Root("test"), + PriorValue: testtypes.DynamicValueWithSemanticEquals{ + DynamicValue: types.DynamicValue(types.StringValue("prior")), + SemanticEquals: false, + }, + ProposedNewValue: testtypes.DynamicValueWithSemanticEquals{ + DynamicValue: types.DynamicValue(types.StringValue("new")), + SemanticEquals: false, + }, + }, + expected: &fwschemadata.ValueSemanticEqualityResponse{ + NewValue: testtypes.DynamicValueWithSemanticEquals{ + DynamicValue: types.DynamicValue(types.StringValue("new")), + SemanticEquals: false, + }, + }, + }, + "DynamicValuableWithSemanticEquals-false-underlying-prior-value-null": { + request: fwschemadata.ValueSemanticEqualityRequest{ + Path: path.Root("test"), + PriorValue: testtypes.DynamicValueWithSemanticEquals{ + DynamicValue: types.DynamicValue(types.StringNull()), + SemanticEquals: true, // semantic equality won't be called because underlying prior value is null + }, + ProposedNewValue: testtypes.DynamicValueWithSemanticEquals{ + DynamicValue: types.DynamicValue(types.StringValue("new")), + SemanticEquals: true, // semantic equality won't be called because underlying prior value is null + }, + }, + expected: &fwschemadata.ValueSemanticEqualityResponse{ + NewValue: testtypes.DynamicValueWithSemanticEquals{ + DynamicValue: types.DynamicValue(types.StringValue("new")), + SemanticEquals: false, + }, + }, + }, + "DynamicValuableWithSemanticEquals-false-underlying-prior-value-unknown": { + request: fwschemadata.ValueSemanticEqualityRequest{ + Path: path.Root("test"), + PriorValue: testtypes.DynamicValueWithSemanticEquals{ + DynamicValue: types.DynamicValue(types.StringUnknown()), + SemanticEquals: true, // semantic equality won't be called because underlying prior value is unknown + }, + ProposedNewValue: testtypes.DynamicValueWithSemanticEquals{ + DynamicValue: types.DynamicValue(types.StringValue("new")), + SemanticEquals: true, // semantic equality won't be called because underlying prior value is unknown + }, + }, + expected: &fwschemadata.ValueSemanticEqualityResponse{ + NewValue: testtypes.DynamicValueWithSemanticEquals{ + DynamicValue: types.DynamicValue(types.StringValue("new")), + SemanticEquals: false, + }, + }, + }, + "DynamicValuableWithSemanticEquals-false-underlying-proposed-value-null": { + request: fwschemadata.ValueSemanticEqualityRequest{ + Path: path.Root("test"), + PriorValue: testtypes.DynamicValueWithSemanticEquals{ + DynamicValue: types.DynamicValue(types.StringValue("prior")), + SemanticEquals: true, // semantic equality won't be called because underlying proposed value is null + }, + ProposedNewValue: testtypes.DynamicValueWithSemanticEquals{ + DynamicValue: types.DynamicValue(types.StringNull()), + SemanticEquals: true, // semantic equality won't be called because underlying proposed value is null + }, + }, + expected: &fwschemadata.ValueSemanticEqualityResponse{ + NewValue: testtypes.DynamicValueWithSemanticEquals{ + DynamicValue: types.DynamicValue(types.StringNull()), + SemanticEquals: false, + }, + }, + }, + "DynamicValuableWithSemanticEquals-false-underlying-proposed-value-unknown": { + request: fwschemadata.ValueSemanticEqualityRequest{ + Path: path.Root("test"), + PriorValue: testtypes.DynamicValueWithSemanticEquals{ + DynamicValue: types.DynamicValue(types.StringValue("prior")), + SemanticEquals: true, // semantic equality won't be called because underlying proposed value is unknown + }, + ProposedNewValue: testtypes.DynamicValueWithSemanticEquals{ + DynamicValue: types.DynamicValue(types.StringUnknown()), + SemanticEquals: true, // semantic equality won't be called because underlying proposed value is unknown + }, + }, + expected: &fwschemadata.ValueSemanticEqualityResponse{ + NewValue: testtypes.DynamicValueWithSemanticEquals{ + DynamicValue: types.DynamicValue(types.StringUnknown()), + SemanticEquals: false, + }, + }, + }, + "DynamicValuableWithSemanticEquals-diagnostics": { + request: fwschemadata.ValueSemanticEqualityRequest{ + Path: path.Root("test"), + PriorValue: testtypes.DynamicValueWithSemanticEquals{ + DynamicValue: types.DynamicValue(types.StringValue("prior")), + SemanticEquals: false, + SemanticEqualsDiagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic("test summary 1", "test detail 1"), + diag.NewErrorDiagnostic("test summary 2", "test detail 2"), + }, + }, + ProposedNewValue: testtypes.DynamicValueWithSemanticEquals{ + DynamicValue: types.DynamicValue(types.StringValue("new")), + SemanticEquals: false, + SemanticEqualsDiagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic("test summary 1", "test detail 1"), + diag.NewErrorDiagnostic("test summary 2", "test detail 2"), + }, + }, + }, + expected: &fwschemadata.ValueSemanticEqualityResponse{ + NewValue: testtypes.DynamicValueWithSemanticEquals{ + DynamicValue: types.DynamicValue(types.StringValue("new")), + SemanticEquals: false, + SemanticEqualsDiagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic("test summary 1", "test detail 1"), + diag.NewErrorDiagnostic("test summary 2", "test detail 2"), + }, + }, + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic("test summary 1", "test detail 1"), + diag.NewErrorDiagnostic("test summary 2", "test detail 2"), + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := &fwschemadata.ValueSemanticEqualityResponse{ + NewValue: testCase.request.ProposedNewValue, + } + + fwschemadata.ValueSemanticEqualityDynamic(context.Background(), testCase.request, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/internal/fwserver/attr_type.go b/internal/fwserver/attr_type.go index 74ab45fdd..85dd3bff0 100644 --- a/internal/fwserver/attr_type.go +++ b/internal/fwserver/attr_type.go @@ -145,3 +145,18 @@ func coerceStringTypable(ctx context.Context, schemaPath path.Path, valuable bas return typable, nil } + +func coerceDynamicTypable(ctx context.Context, schemaPath path.Path, valuable basetypes.DynamicValuable) (basetypes.DynamicTypable, diag.Diagnostics) { + typable, ok := valuable.Type(ctx).(basetypes.DynamicTypable) + + // Type() of a Valuable should always be a Typable to recreate the Valuable, + // but if for some reason it is not, raise an implementation error instead + // of a panic. + if !ok { + return nil, diag.Diagnostics{ + attributePlanModificationTypableError(schemaPath, valuable), + } + } + + return typable, nil +} diff --git a/internal/fwserver/attribute_plan_modification.go b/internal/fwserver/attribute_plan_modification.go index e73b208fb..36e86dec5 100644 --- a/internal/fwserver/attribute_plan_modification.go +++ b/internal/fwserver/attribute_plan_modification.go @@ -108,6 +108,8 @@ func AttributeModifyPlan(ctx context.Context, a fwschema.Attribute, req ModifyAt AttributePlanModifySet(ctx, attributeWithPlanModifiers, req, resp) case fwxschema.AttributeWithStringPlanModifiers: AttributePlanModifyString(ctx, attributeWithPlanModifiers, req, resp) + case fwxschema.AttributeWithDynamicPlanModifiers: + AttributePlanModifyDynamic(ctx, attributeWithPlanModifiers, req, resp) } if resp.Diagnostics.HasError() { @@ -2119,6 +2121,166 @@ func AttributePlanModifyString(ctx context.Context, attribute fwxschema.Attribut } } +// AttributePlanModifyDynamic performs all types.Dynamic plan modification. +func AttributePlanModifyDynamic(ctx context.Context, attribute fwxschema.AttributeWithDynamicPlanModifiers, req ModifyAttributePlanRequest, resp *ModifyAttributePlanResponse) { + // Use basetypes.DynamicValuable until custom types cannot re-implement + // ValueFromTerraform. Until then, custom types are not technically + // required to implement this interface. This opts to enforce the + // requirement before compatibility promises would interfere. + configValuable, ok := req.AttributeConfig.(basetypes.DynamicValuable) + + if !ok { + resp.Diagnostics.AddAttributeError( + req.AttributePath, + "Invalid Dynamic Attribute Plan Modifier Value Type", + "An unexpected value type was encountered while attempting to perform Dynamic attribute plan modification. "+ + "The value type must implement the basetypes.DynamicValuable interface. "+ + "Please report this to the provider developers.\n\n"+ + fmt.Sprintf("Incoming Value Type: %T", req.AttributeConfig), + ) + + return + } + + configValue, diags := configValuable.ToDynamicValue(ctx) + + resp.Diagnostics.Append(diags...) + + // Only return early on new errors as the resp.Diagnostics may have errors + // from other attributes. + if diags.HasError() { + return + } + + planValuable, ok := req.AttributePlan.(basetypes.DynamicValuable) + + if !ok { + resp.Diagnostics.AddAttributeError( + req.AttributePath, + "Invalid Dynamic Attribute Plan Modifier Value Type", + "An unexpected value type was encountered while attempting to perform Dynamic attribute plan modification. "+ + "The value type must implement the basetypes.DynamicValuable interface. "+ + "Please report this to the provider developers.\n\n"+ + fmt.Sprintf("Incoming Value Type: %T", req.AttributePlan), + ) + + return + } + + planValue, diags := planValuable.ToDynamicValue(ctx) + + resp.Diagnostics.Append(diags...) + + // Only return early on new errors as the resp.Diagnostics may have errors + // from other attributes. + if diags.HasError() { + return + } + + stateValuable, ok := req.AttributeState.(basetypes.DynamicValuable) + + if !ok { + resp.Diagnostics.AddAttributeError( + req.AttributePath, + "Invalid Dynamic Attribute Plan Modifier Value Type", + "An unexpected value type was encountered while attempting to perform Dynamic attribute plan modification. "+ + "The value type must implement the basetypes.DynamicValuable interface. "+ + "Please report this to the provider developers.\n\n"+ + fmt.Sprintf("Incoming Value Type: %T", req.AttributeState), + ) + + return + } + + stateValue, diags := stateValuable.ToDynamicValue(ctx) + + resp.Diagnostics.Append(diags...) + + // Only return early on new errors as the resp.Diagnostics may have errors + // from other attributes. + if diags.HasError() { + return + } + + typable, diags := coerceDynamicTypable(ctx, req.AttributePath, planValuable) + + resp.Diagnostics.Append(diags...) + + // Only return early on new errors as the resp.Diagnostics may have errors + // from other attributes. + if diags.HasError() { + return + } + + planModifyReq := planmodifier.DynamicRequest{ + Config: req.Config, + ConfigValue: configValue, + Path: req.AttributePath, + PathExpression: req.AttributePathExpression, + Plan: req.Plan, + PlanValue: planValue, + Private: req.Private, + State: req.State, + StateValue: stateValue, + } + + for _, planModifier := range attribute.DynamicPlanModifiers() { + // Instantiate a new response for each request to prevent plan modifiers + // from modifying or removing diagnostics. + planModifyResp := &planmodifier.DynamicResponse{ + PlanValue: planModifyReq.PlanValue, + Private: resp.Private, + } + + logging.FrameworkTrace( + ctx, + "Calling provider defined planmodifier.Dynamic", + map[string]interface{}{ + logging.KeyDescription: planModifier.Description(ctx), + }, + ) + + planModifier.PlanModifyDynamic(ctx, planModifyReq, planModifyResp) + + logging.FrameworkTrace( + ctx, + "Called provider defined planmodifier.Dynamic", + map[string]interface{}{ + logging.KeyDescription: planModifier.Description(ctx), + }, + ) + + // Prepare next request with base type. + planModifyReq.PlanValue = planModifyResp.PlanValue + + resp.Diagnostics.Append(planModifyResp.Diagnostics...) + resp.Private = planModifyResp.Private + + if planModifyResp.RequiresReplace { + resp.RequiresReplace.Append(req.AttributePath) + } + + // Only on new errors. + if planModifyResp.Diagnostics.HasError() { + return + } + + // A custom value type must be returned in the final response to prevent + // later correctness errors. + // Reference: https://github.com/hashicorp/terraform-plugin-framework/issues/754 + valuable, valueFromDiags := typable.ValueFromDynamic(ctx, planModifyResp.PlanValue) + + resp.Diagnostics.Append(valueFromDiags...) + + // Only on new errors. + if valueFromDiags.HasError() { + return + } + + resp.AttributePlan = valuable + } +} + func NestedAttributeObjectPlanModify(ctx context.Context, o fwschema.NestedAttributeObject, req planmodifier.ObjectRequest, resp *ModifyAttributePlanResponse) { if objectWithPlanModifiers, ok := o.(fwxschema.NestedAttributeObjectWithPlanModifiers); ok { for _, objectPlanModifier := range objectWithPlanModifiers.ObjectPlanModifiers() { diff --git a/internal/fwserver/attribute_plan_modification_test.go b/internal/fwserver/attribute_plan_modification_test.go index d135926f3..28d86d76a 100644 --- a/internal/fwserver/attribute_plan_modification_test.go +++ b/internal/fwserver/attribute_plan_modification_test.go @@ -9975,6 +9975,642 @@ func TestAttributePlanModifyString(t *testing.T) { } } +func TestAttributePlanModifyDynamic(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attribute fwxschema.AttributeWithDynamicPlanModifiers + request ModifyAttributePlanRequest + response *ModifyAttributePlanResponse + expected *ModifyAttributePlanResponse + }{ + "request-path": { + attribute: testschema.AttributeWithDynamicPlanModifiers{ + PlanModifiers: []planmodifier.Dynamic{ + testplanmodifier.Dynamic{ + PlanModifyDynamicMethod: func(ctx context.Context, req planmodifier.DynamicRequest, resp *planmodifier.DynamicResponse) { + got := req.Path + expected := path.Root("test") + + if !got.Equal(expected) { + resp.Diagnostics.AddError( + "Unexpected DynamicRequest.Path", + fmt.Sprintf("expected %s, got: %s", expected, got), + ) + } + }, + }, + }, + }, + request: ModifyAttributePlanRequest{ + AttributePath: path.Root("test"), + AttributeConfig: types.DynamicValue(types.StringValue("testvalue")), + AttributePlan: types.DynamicValue(types.StringValue("testvalue")), + AttributeState: types.DynamicValue(types.StringValue("testvalue")), + }, + response: &ModifyAttributePlanResponse{ + AttributePlan: types.DynamicValue(types.StringValue("testvalue")), + }, + expected: &ModifyAttributePlanResponse{ + AttributePlan: types.DynamicValue(types.StringValue("testvalue")), + }, + }, + "request-pathexpression": { + attribute: testschema.AttributeWithDynamicPlanModifiers{ + PlanModifiers: []planmodifier.Dynamic{ + testplanmodifier.Dynamic{ + PlanModifyDynamicMethod: func(ctx context.Context, req planmodifier.DynamicRequest, resp *planmodifier.DynamicResponse) { + got := req.PathExpression + expected := path.MatchRoot("test") + + if !got.Equal(expected) { + resp.Diagnostics.AddError( + "Unexpected DynamicRequest.PathExpression", + fmt.Sprintf("expected %s, got: %s", expected, got), + ) + } + }, + }, + }, + }, + request: ModifyAttributePlanRequest{ + AttributePath: path.Root("test"), + AttributePathExpression: path.MatchRoot("test"), + AttributeConfig: types.DynamicValue(types.StringValue("testvalue")), + AttributePlan: types.DynamicValue(types.StringValue("testvalue")), + AttributeState: types.DynamicValue(types.StringValue("testvalue")), + }, + response: &ModifyAttributePlanResponse{ + AttributePlan: types.DynamicValue(types.StringValue("testvalue")), + }, + expected: &ModifyAttributePlanResponse{ + AttributePlan: types.DynamicValue(types.StringValue("testvalue")), + }, + }, + "request-config": { + attribute: testschema.AttributeWithDynamicPlanModifiers{ + PlanModifiers: []planmodifier.Dynamic{ + testplanmodifier.Dynamic{ + PlanModifyDynamicMethod: func(ctx context.Context, req planmodifier.DynamicRequest, resp *planmodifier.DynamicResponse) { + got := req.Config + expected := tfsdk.Config{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.DynamicPseudoType, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "testvalue"), + }, + ), + } + + if !got.Raw.Equal(expected.Raw) { + resp.Diagnostics.AddError( + "Unexpected DynamicRequest.Config", + fmt.Sprintf("expected %s, got: %s", expected.Raw, got.Raw), + ) + } + }, + }, + }, + }, + request: ModifyAttributePlanRequest{ + AttributePath: path.Root("test"), + AttributeConfig: types.DynamicValue(types.StringValue("testvalue")), + AttributePlan: types.DynamicValue(types.StringValue("testvalue")), + AttributeState: types.DynamicValue(types.StringValue("testvalue")), + Config: tfsdk.Config{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.DynamicPseudoType, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "testvalue"), + }, + ), + }, + }, + response: &ModifyAttributePlanResponse{ + AttributePlan: types.DynamicValue(types.StringValue("testvalue")), + }, + expected: &ModifyAttributePlanResponse{ + AttributePlan: types.DynamicValue(types.StringValue("testvalue")), + }, + }, + "request-configvalue": { + attribute: testschema.AttributeWithDynamicPlanModifiers{ + PlanModifiers: []planmodifier.Dynamic{ + testplanmodifier.Dynamic{ + PlanModifyDynamicMethod: func(ctx context.Context, req planmodifier.DynamicRequest, resp *planmodifier.DynamicResponse) { + got := req.ConfigValue + expected := types.DynamicValue(types.StringValue("testvalue")) + + if !got.Equal(expected) { + resp.Diagnostics.AddError( + "Unexpected DynamicRequest.ConfigValue", + fmt.Sprintf("expected %s, got: %s", expected, got), + ) + } + }, + }, + }, + }, + request: ModifyAttributePlanRequest{ + AttributePath: path.Root("test"), + AttributeConfig: types.DynamicValue(types.StringValue("testvalue")), + AttributePlan: types.DynamicNull(), + AttributeState: types.DynamicNull(), + }, + response: &ModifyAttributePlanResponse{ + AttributePlan: types.DynamicNull(), + }, + expected: &ModifyAttributePlanResponse{ + AttributePlan: types.DynamicNull(), + }, + }, + "request-plan": { + attribute: testschema.AttributeWithDynamicPlanModifiers{ + PlanModifiers: []planmodifier.Dynamic{ + testplanmodifier.Dynamic{ + PlanModifyDynamicMethod: func(ctx context.Context, req planmodifier.DynamicRequest, resp *planmodifier.DynamicResponse) { + got := req.Plan + expected := tfsdk.Plan{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.DynamicPseudoType, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "testvalue"), + }, + ), + } + + if !got.Raw.Equal(expected.Raw) { + resp.Diagnostics.AddError( + "Unexpected DynamicRequest.Plan", + fmt.Sprintf("expected %s, got: %s", expected.Raw, got.Raw), + ) + } + }, + }, + }, + }, + request: ModifyAttributePlanRequest{ + AttributePath: path.Root("test"), + AttributeConfig: types.DynamicValue(types.StringValue("testvalue")), + AttributePlan: types.DynamicValue(types.StringValue("testvalue")), + AttributeState: types.DynamicValue(types.StringValue("testvalue")), + Plan: tfsdk.Plan{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.DynamicPseudoType, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "testvalue"), + }, + ), + }, + }, + response: &ModifyAttributePlanResponse{ + AttributePlan: types.DynamicValue(types.StringValue("testvalue")), + }, + expected: &ModifyAttributePlanResponse{ + AttributePlan: types.DynamicValue(types.StringValue("testvalue")), + }, + }, + "request-planvalue": { + attribute: testschema.AttributeWithDynamicPlanModifiers{ + PlanModifiers: []planmodifier.Dynamic{ + testplanmodifier.Dynamic{ + PlanModifyDynamicMethod: func(ctx context.Context, req planmodifier.DynamicRequest, resp *planmodifier.DynamicResponse) { + got := req.PlanValue + expected := types.DynamicValue(types.StringValue("testvalue")) + + if !got.Equal(expected) { + resp.Diagnostics.AddError( + "Unexpected DynamicRequest.PlanValue", + fmt.Sprintf("expected %s, got: %s", expected, got), + ) + } + }, + }, + }, + }, + request: ModifyAttributePlanRequest{ + AttributePath: path.Root("test"), + AttributeConfig: types.DynamicNull(), + AttributePlan: types.DynamicValue(types.StringValue("testvalue")), + AttributeState: types.DynamicNull(), + }, + response: &ModifyAttributePlanResponse{ + AttributePlan: types.DynamicValue(types.StringValue("testvalue")), + }, + expected: &ModifyAttributePlanResponse{ + AttributePlan: types.DynamicValue(types.StringValue("testvalue")), + }, + }, + "request-private": { + attribute: testschema.AttributeWithDynamicPlanModifiers{ + PlanModifiers: []planmodifier.Dynamic{ + testplanmodifier.Dynamic{ + PlanModifyDynamicMethod: func(ctx context.Context, req planmodifier.DynamicRequest, resp *planmodifier.DynamicResponse) { + got, diags := req.Private.GetKey(ctx, "testkey") + expected := []byte(`{"testproperty":true}`) + + resp.Diagnostics.Append(diags...) + + if diff := cmp.Diff(got, expected); diff != "" { + resp.Diagnostics.AddError( + "Unexpected DynamicRequest.Private", + diff, + ) + } + }, + }, + }, + }, + request: ModifyAttributePlanRequest{ + AttributePath: path.Root("test"), + AttributeConfig: types.DynamicNull(), + AttributePlan: types.DynamicValue(types.StringValue("testvalue")), + AttributeState: types.DynamicNull(), + Private: privatestate.MustProviderData( + context.Background(), + privatestate.MustMarshalToJson(map[string][]byte{ + "testkey": []byte(`{"testproperty":true}`), + }), + ), + }, + response: &ModifyAttributePlanResponse{ + AttributePlan: types.DynamicValue(types.StringValue("testvalue")), + Private: privatestate.MustProviderData( + context.Background(), + privatestate.MustMarshalToJson(map[string][]byte{ + "testkey": []byte(`{"testproperty":true}`), // copied from request + }), + ), + }, + expected: &ModifyAttributePlanResponse{ + AttributePlan: types.DynamicValue(types.StringValue("testvalue")), + Private: privatestate.MustProviderData( + context.Background(), + privatestate.MustMarshalToJson(map[string][]byte{ + "testkey": []byte(`{"testproperty":true}`), + }), + ), + }, + }, + "request-state": { + attribute: testschema.AttributeWithDynamicPlanModifiers{ + PlanModifiers: []planmodifier.Dynamic{ + testplanmodifier.Dynamic{ + PlanModifyDynamicMethod: func(ctx context.Context, req planmodifier.DynamicRequest, resp *planmodifier.DynamicResponse) { + got := req.State + expected := tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.DynamicPseudoType, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "testvalue"), + }, + ), + } + + if !got.Raw.Equal(expected.Raw) { + resp.Diagnostics.AddError( + "Unexpected DynamicRequest.State", + fmt.Sprintf("expected %s, got: %s", expected.Raw, got.Raw), + ) + } + }, + }, + }, + }, + request: ModifyAttributePlanRequest{ + AttributePath: path.Root("test"), + AttributeConfig: types.DynamicValue(types.StringValue("testvalue")), + AttributePlan: types.DynamicValue(types.StringValue("testvalue")), + AttributeState: types.DynamicValue(types.StringValue("testvalue")), + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.DynamicPseudoType, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "testvalue"), + }, + ), + }, + }, + response: &ModifyAttributePlanResponse{ + AttributePlan: types.DynamicValue(types.StringValue("testvalue")), + }, + expected: &ModifyAttributePlanResponse{ + AttributePlan: types.DynamicValue(types.StringValue("testvalue")), + }, + }, + "request-statevalue": { + attribute: testschema.AttributeWithDynamicPlanModifiers{ + PlanModifiers: []planmodifier.Dynamic{ + testplanmodifier.Dynamic{ + PlanModifyDynamicMethod: func(ctx context.Context, req planmodifier.DynamicRequest, resp *planmodifier.DynamicResponse) { + got := req.StateValue + expected := types.DynamicValue(types.StringValue("testvalue")) + + if !got.Equal(expected) { + resp.Diagnostics.AddError( + "Unexpected DynamicRequest.StateValue", + fmt.Sprintf("expected %s, got: %s", expected, got), + ) + } + }, + }, + }, + }, + request: ModifyAttributePlanRequest{ + AttributePath: path.Root("test"), + AttributeConfig: types.DynamicNull(), + AttributePlan: types.DynamicNull(), + AttributeState: types.DynamicValue(types.StringValue("testvalue")), + }, + response: &ModifyAttributePlanResponse{ + AttributePlan: types.DynamicNull(), + }, + expected: &ModifyAttributePlanResponse{ + AttributePlan: types.DynamicNull(), + }, + }, + "response-diagnostics": { + attribute: testschema.AttributeWithDynamicPlanModifiers{ + PlanModifiers: []planmodifier.Dynamic{ + testplanmodifier.Dynamic{ + PlanModifyDynamicMethod: func(ctx context.Context, req planmodifier.DynamicRequest, resp *planmodifier.DynamicResponse) { + resp.Diagnostics.AddAttributeWarning(req.Path, "New Warning Summary", "New Warning Details") + resp.Diagnostics.AddAttributeError(req.Path, "New Error Summary", "New Error Details") + }, + }, + }, + }, + request: ModifyAttributePlanRequest{ + AttributePath: path.Root("test"), + AttributeConfig: types.DynamicValue(types.StringValue("testvalue")), + AttributePlan: types.DynamicValue(types.StringValue("testvalue")), + AttributeState: types.DynamicValue(types.StringValue("testvalue")), + }, + response: &ModifyAttributePlanResponse{ + AttributePlan: types.DynamicValue(types.StringValue("testvalue")), + Diagnostics: diag.Diagnostics{ + diag.NewAttributeWarningDiagnostic( + path.Root("other"), + "Existing Warning Summary", + "Existing Warning Details", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("other"), + "Existing Error Summary", + "Existing Error Details", + ), + }, + }, + expected: &ModifyAttributePlanResponse{ + AttributePlan: types.DynamicValue(types.StringValue("testvalue")), + Diagnostics: diag.Diagnostics{ + diag.NewAttributeWarningDiagnostic( + path.Root("other"), + "Existing Warning Summary", + "Existing Warning Details", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("other"), + "Existing Error Summary", + "Existing Error Details", + ), + diag.NewAttributeWarningDiagnostic( + path.Root("test"), + "New Warning Summary", + "New Warning Details", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "New Error Summary", + "New Error Details", + ), + }, + }, + }, + "response-planvalue": { + attribute: testschema.AttributeWithDynamicPlanModifiers{ + PlanModifiers: []planmodifier.Dynamic{ + testplanmodifier.Dynamic{ + PlanModifyDynamicMethod: func(ctx context.Context, req planmodifier.DynamicRequest, resp *planmodifier.DynamicResponse) { + resp.PlanValue = types.DynamicValue(types.StringValue("testvalue")) + }, + }, + }, + }, + request: ModifyAttributePlanRequest{ + AttributePath: path.Root("test"), + AttributeConfig: types.DynamicNull(), + AttributePlan: types.DynamicUnknown(), + AttributeState: types.DynamicNull(), + }, + response: &ModifyAttributePlanResponse{ + AttributePlan: types.DynamicUnknown(), + }, + expected: &ModifyAttributePlanResponse{ + AttributePlan: types.DynamicValue(types.StringValue("testvalue")), + }, + }, + "response-planvalue-custom-type": { + attribute: testschema.AttributeWithDynamicPlanModifiers{ + PlanModifiers: []planmodifier.Dynamic{ + testplanmodifier.Dynamic{ + PlanModifyDynamicMethod: func(ctx context.Context, req planmodifier.DynamicRequest, resp *planmodifier.DynamicResponse) { + resp.PlanValue = types.DynamicValue(types.StringValue("testvalue")) + }, + }, + }, + }, + request: ModifyAttributePlanRequest{ + AttributePath: path.Root("test"), + AttributeConfig: testtypes.DynamicValueWithSemanticEquals{ + DynamicValue: types.DynamicNull(), + }, + AttributePlan: testtypes.DynamicValueWithSemanticEquals{ + DynamicValue: types.DynamicUnknown(), + }, + AttributeState: testtypes.DynamicValueWithSemanticEquals{ + DynamicValue: types.DynamicNull(), + }, + }, + response: &ModifyAttributePlanResponse{ + AttributePlan: testtypes.DynamicValueWithSemanticEquals{ + DynamicValue: types.DynamicUnknown(), + }, + }, + expected: &ModifyAttributePlanResponse{ + AttributePlan: testtypes.DynamicValueWithSemanticEquals{ + DynamicValue: types.DynamicValue(types.StringValue("testvalue")), + }, + }, + }, + "response-private": { + attribute: testschema.AttributeWithDynamicPlanModifiers{ + PlanModifiers: []planmodifier.Dynamic{ + testplanmodifier.Dynamic{ + PlanModifyDynamicMethod: func(ctx context.Context, req planmodifier.DynamicRequest, resp *planmodifier.DynamicResponse) { + resp.Diagnostics.Append( + resp.Private.SetKey(ctx, "testkey", []byte(`{"newtestproperty":true}`))..., + ) + }, + }, + }, + }, + request: ModifyAttributePlanRequest{ + AttributePath: path.Root("test"), + AttributeConfig: types.DynamicNull(), + AttributePlan: types.DynamicValue(types.StringValue("testvalue")), + AttributeState: types.DynamicNull(), + Private: privatestate.MustProviderData( + context.Background(), + privatestate.MustMarshalToJson(map[string][]byte{ + "testkey": []byte(`{"testproperty":true}`), + }), + ), + }, + response: &ModifyAttributePlanResponse{ + AttributePlan: types.DynamicValue(types.StringValue("testvalue")), + Private: privatestate.MustProviderData( + context.Background(), + privatestate.MustMarshalToJson(map[string][]byte{ + "testkey": []byte(`{"testproperty":true}`), // copied from request + }), + ), + }, + expected: &ModifyAttributePlanResponse{ + AttributePlan: types.DynamicValue(types.StringValue("testvalue")), + Private: privatestate.MustProviderData( + context.Background(), + privatestate.MustMarshalToJson(map[string][]byte{ + "testkey": []byte(`{"newtestproperty":true}`), + }), + ), + }, + }, + "response-requiresreplace-add": { + attribute: testschema.AttributeWithDynamicPlanModifiers{ + PlanModifiers: []planmodifier.Dynamic{ + testplanmodifier.Dynamic{ + PlanModifyDynamicMethod: func(ctx context.Context, req planmodifier.DynamicRequest, resp *planmodifier.DynamicResponse) { + resp.RequiresReplace = true + }, + }, + }, + }, + request: ModifyAttributePlanRequest{ + AttributePath: path.Root("test"), + AttributeConfig: types.DynamicValue(types.StringValue("testvalue")), + AttributePlan: types.DynamicValue(types.StringValue("testvalue")), + AttributeState: types.DynamicValue(types.StringValue("oldtestvalue")), + }, + response: &ModifyAttributePlanResponse{ + AttributePlan: types.DynamicValue(types.StringValue("testvalue")), + }, + expected: &ModifyAttributePlanResponse{ + AttributePlan: types.DynamicValue(types.StringValue("testvalue")), + RequiresReplace: path.Paths{ + path.Root("test"), + }, + }, + }, + "response-requiresreplace-false": { + attribute: testschema.AttributeWithDynamicPlanModifiers{ + PlanModifiers: []planmodifier.Dynamic{ + testplanmodifier.Dynamic{ + PlanModifyDynamicMethod: func(ctx context.Context, req planmodifier.DynamicRequest, resp *planmodifier.DynamicResponse) { + resp.RequiresReplace = false // same as not being set + }, + }, + }, + }, + request: ModifyAttributePlanRequest{ + AttributePath: path.Root("test"), + AttributeConfig: types.DynamicValue(types.StringValue("testvalue")), + AttributePlan: types.DynamicValue(types.StringValue("testvalue")), + AttributeState: types.DynamicValue(types.StringValue("oldtestvalue")), + }, + response: &ModifyAttributePlanResponse{ + AttributePlan: types.DynamicValue(types.StringValue("testvalue")), + RequiresReplace: path.Paths{ + path.Root("test"), // Set by prior plan modifier + }, + }, + expected: &ModifyAttributePlanResponse{ + AttributePlan: types.DynamicValue(types.StringValue("testvalue")), + RequiresReplace: path.Paths{ + path.Root("test"), // Remains as it should not be removed + }, + }, + }, + "response-requiresreplace-update": { + attribute: testschema.AttributeWithDynamicPlanModifiers{ + PlanModifiers: []planmodifier.Dynamic{ + testplanmodifier.Dynamic{ + PlanModifyDynamicMethod: func(ctx context.Context, req planmodifier.DynamicRequest, resp *planmodifier.DynamicResponse) { + resp.RequiresReplace = true + }, + }, + }, + }, + request: ModifyAttributePlanRequest{ + AttributePath: path.Root("test"), + AttributeConfig: types.DynamicValue(types.StringValue("testvalue")), + AttributePlan: types.DynamicValue(types.StringValue("testvalue")), + AttributeState: types.DynamicValue(types.StringValue("oldtestvalue")), + }, + response: &ModifyAttributePlanResponse{ + AttributePlan: types.DynamicValue(types.StringValue("testvalue")), + RequiresReplace: path.Paths{ + path.Root("test"), // Set by prior plan modifier + }, + }, + expected: &ModifyAttributePlanResponse{ + AttributePlan: types.DynamicValue(types.StringValue("testvalue")), + RequiresReplace: path.Paths{ + path.Root("test"), // Remains deduplicated + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + AttributePlanModifyDynamic(context.Background(), testCase.attribute, testCase.request, testCase.response) + + if diff := cmp.Diff(testCase.response, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + func TestNestedAttributeObjectPlanModify(t *testing.T) { t.Parallel() diff --git a/internal/fwserver/attribute_validation.go b/internal/fwserver/attribute_validation.go index da5e5835c..1fa4883b0 100644 --- a/internal/fwserver/attribute_validation.go +++ b/internal/fwserver/attribute_validation.go @@ -127,17 +127,40 @@ func AttributeValidate(ctx context.Context, a fwschema.Attribute, req ValidateAt AttributeValidateSet(ctx, attributeWithValidators, req, resp) case fwxschema.AttributeWithStringValidators: AttributeValidateString(ctx, attributeWithValidators, req, resp) + case fwxschema.AttributeWithDynamicValidators: + AttributeValidateDynamic(ctx, attributeWithValidators, req, resp) } AttributeValidateNestedAttributes(ctx, a, req, resp) // Show deprecation warnings only for known values. if a.GetDeprecationMessage() != "" && !attributeConfig.IsNull() && !attributeConfig.IsUnknown() { - resp.Diagnostics.AddAttributeWarning( - req.AttributePath, - "Attribute Deprecated", - a.GetDeprecationMessage(), - ) + // Dynamic values need to perform more logic to check the config value for null/unknown-ness + dynamicValuable, ok := attributeConfig.(basetypes.DynamicValuable) + if !ok { + resp.Diagnostics.AddAttributeWarning( + req.AttributePath, + "Attribute Deprecated", + a.GetDeprecationMessage(), + ) + return + } + + dynamicConfigVal, diags := dynamicValuable.ToDynamicValue(ctx) + resp.Diagnostics.Append(diags...) + if diags.HasError() { + return + } + + // For dynamic values, it's possible to be known when only the type is known. + // The underlying value can still be null or unknown, so check for that here + if !dynamicConfigVal.IsUnderlyingValueNull() && !dynamicConfigVal.IsUnderlyingValueUnknown() { + resp.Diagnostics.AddAttributeWarning( + req.AttributePath, + "Attribute Deprecated", + a.GetDeprecationMessage(), + ) + } } } @@ -726,6 +749,71 @@ func AttributeValidateString(ctx context.Context, attribute fwxschema.AttributeW } } +// AttributeValidateDynamic performs all types.Dynamic validation. +func AttributeValidateDynamic(ctx context.Context, attribute fwxschema.AttributeWithDynamicValidators, req ValidateAttributeRequest, resp *ValidateAttributeResponse) { + // Use basetypes.DynamicValuable until custom types cannot re-implement + // ValueFromTerraform. Until then, custom types are not technically + // required to implement this interface. This opts to enforce the + // requirement before compatibility promises would interfere. + configValuable, ok := req.AttributeConfig.(basetypes.DynamicValuable) + + if !ok { + resp.Diagnostics.AddAttributeError( + req.AttributePath, + "Invalid Dynamic Attribute Validator Value Type", + "An unexpected value type was encountered while attempting to perform Dynamic attribute validation. "+ + "The value type must implement the basetypes.DynamicValuable interface. "+ + "Please report this to the provider developers.\n\n"+ + fmt.Sprintf("Incoming Value Type: %T", req.AttributeConfig), + ) + + return + } + + configValue, diags := configValuable.ToDynamicValue(ctx) + + resp.Diagnostics.Append(diags...) + + // Only return early on new errors as the resp.Diagnostics may have errors + // from other attributes. + if diags.HasError() { + return + } + + validateReq := validator.DynamicRequest{ + Config: req.Config, + ConfigValue: configValue, + Path: req.AttributePath, + PathExpression: req.AttributePathExpression, + } + + for _, attributeValidator := range attribute.DynamicValidators() { + // Instantiate a new response for each request to prevent validators + // from modifying or removing diagnostics. + validateResp := &validator.DynamicResponse{} + + logging.FrameworkTrace( + ctx, + "Calling provider defined validator.Dynamic", + map[string]interface{}{ + logging.KeyDescription: attributeValidator.Description(ctx), + }, + ) + + attributeValidator.ValidateDynamic(ctx, validateReq, validateResp) + + logging.FrameworkTrace( + ctx, + "Called provider defined validator.Dynamic", + map[string]interface{}{ + logging.KeyDescription: attributeValidator.Description(ctx), + }, + ) + + resp.Diagnostics.Append(validateResp.Diagnostics...) + } +} + // AttributeValidateNestedAttributes performs all nested Attributes validation. // // TODO: Clean up this abstraction back into an internal Attribute type method. diff --git a/internal/fwserver/attribute_validation_test.go b/internal/fwserver/attribute_validation_test.go index 99cfbeccd..62ff54f6d 100644 --- a/internal/fwserver/attribute_validation_test.go +++ b/internal/fwserver/attribute_validation_test.go @@ -386,6 +386,38 @@ func TestAttributeValidate(t *testing.T) { }, }, }, + "deprecation-message-known-dynamic": { + req: ValidateAttributeRequest{ + AttributePath: path.Root("test"), + Config: tfsdk.Config{ + Raw: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.DynamicPseudoType, + }, + }, map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "testvalue"), + }), + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "test": testschema.Attribute{ + Type: types.DynamicType, + Optional: true, + DeprecationMessage: "Use something else instead.", + }, + }, + }, + }, + }, + resp: ValidateAttributeResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewAttributeWarningDiagnostic( + path.Root("test"), + "Attribute Deprecated", + "Use something else instead.", + ), + }, + }, + }, "deprecation-message-null": { req: ValidateAttributeRequest{ AttributePath: path.Root("test"), @@ -410,6 +442,30 @@ func TestAttributeValidate(t *testing.T) { }, resp: ValidateAttributeResponse{}, }, + "deprecation-message-dynamic-underlying-value-null": { + req: ValidateAttributeRequest{ + AttributePath: path.Root("test"), + Config: tfsdk.Config{ + Raw: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.DynamicPseudoType, + }, + }, map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, nil), // underlying type is String + }), + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "test": testschema.Attribute{ + Type: types.DynamicType, + Optional: true, + DeprecationMessage: "Use something else instead.", + }, + }, + }, + }, + }, + resp: ValidateAttributeResponse{}, + }, "deprecation-message-unknown": { req: ValidateAttributeRequest{ AttributePath: path.Root("test"), @@ -434,6 +490,30 @@ func TestAttributeValidate(t *testing.T) { }, resp: ValidateAttributeResponse{}, }, + "deprecation-message-dynamic-underlying-value-unknown": { + req: ValidateAttributeRequest{ + AttributePath: path.Root("test"), + Config: tfsdk.Config{ + Raw: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.DynamicPseudoType, + }, + }, map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), // underlying type is String + }), + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "test": testschema.Attribute{ + Type: types.DynamicType, + Optional: true, + DeprecationMessage: "Use something else instead.", + }, + }, + }, + }, + }, + resp: ValidateAttributeResponse{}, + }, "warnings": { req: ValidateAttributeRequest{ AttributePath: path.Root("test"), @@ -3588,6 +3668,209 @@ func TestAttributeValidateString(t *testing.T) { } } +func TestAttributeValidateDynamic(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attribute fwxschema.AttributeWithDynamicValidators + request ValidateAttributeRequest + response *ValidateAttributeResponse + expected *ValidateAttributeResponse + }{ + "request-path": { + attribute: testschema.AttributeWithDynamicValidators{ + Validators: []validator.Dynamic{ + testvalidator.Dynamic{ + ValidateDynamicMethod: func(ctx context.Context, req validator.DynamicRequest, resp *validator.DynamicResponse) { + got := req.Path + expected := path.Root("test") + + if !got.Equal(expected) { + resp.Diagnostics.AddError( + "Unexpected DynamicRequest.Path", + fmt.Sprintf("expected %s, got: %s", expected, got), + ) + } + }, + }, + }, + }, + request: ValidateAttributeRequest{ + AttributePath: path.Root("test"), + AttributeConfig: types.DynamicValue(types.StringValue("test")), + }, + response: &ValidateAttributeResponse{}, + expected: &ValidateAttributeResponse{}, + }, + "request-pathexpression": { + attribute: testschema.AttributeWithDynamicValidators{ + Validators: []validator.Dynamic{ + testvalidator.Dynamic{ + ValidateDynamicMethod: func(ctx context.Context, req validator.DynamicRequest, resp *validator.DynamicResponse) { + got := req.PathExpression + expected := path.MatchRoot("test") + + if !got.Equal(expected) { + resp.Diagnostics.AddError( + "Unexpected DynamicRequest.PathExpression", + fmt.Sprintf("expected %s, got: %s", expected, got), + ) + } + }, + }, + }, + }, + request: ValidateAttributeRequest{ + AttributePath: path.Root("test"), + AttributePathExpression: path.MatchRoot("test"), + AttributeConfig: types.DynamicValue(types.StringValue("test")), + }, + response: &ValidateAttributeResponse{}, + expected: &ValidateAttributeResponse{}, + }, + "request-config": { + attribute: testschema.AttributeWithDynamicValidators{ + Validators: []validator.Dynamic{ + testvalidator.Dynamic{ + ValidateDynamicMethod: func(ctx context.Context, req validator.DynamicRequest, resp *validator.DynamicResponse) { + got := req.Config + expected := tfsdk.Config{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.DynamicPseudoType, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test"), + }, + ), + } + + if !got.Raw.Equal(expected.Raw) { + resp.Diagnostics.AddError( + "Unexpected DynamicRequest.Config", + fmt.Sprintf("expected %s, got: %s", expected.Raw, got.Raw), + ) + } + }, + }, + }, + }, + request: ValidateAttributeRequest{ + AttributePath: path.Root("test"), + AttributeConfig: types.DynamicValue(types.StringValue("test")), + Config: tfsdk.Config{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.DynamicPseudoType, + }, + }, + map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, "test"), + }, + ), + }, + }, + response: &ValidateAttributeResponse{}, + expected: &ValidateAttributeResponse{}, + }, + "request-configvalue": { + attribute: testschema.AttributeWithDynamicValidators{ + Validators: []validator.Dynamic{ + testvalidator.Dynamic{ + ValidateDynamicMethod: func(ctx context.Context, req validator.DynamicRequest, resp *validator.DynamicResponse) { + got := req.ConfigValue + expected := types.DynamicValue(types.StringValue("test")) + + if !got.Equal(expected) { + resp.Diagnostics.AddError( + "Unexpected DynamicRequest.ConfigValue", + fmt.Sprintf("expected %s, got: %s", expected, got), + ) + } + }, + }, + }, + }, + request: ValidateAttributeRequest{ + AttributePath: path.Root("test"), + AttributeConfig: types.DynamicValue(types.StringValue("test")), + }, + response: &ValidateAttributeResponse{}, + expected: &ValidateAttributeResponse{}, + }, + "response-diagnostics": { + attribute: testschema.AttributeWithDynamicValidators{ + Validators: []validator.Dynamic{ + testvalidator.Dynamic{ + ValidateDynamicMethod: func(ctx context.Context, req validator.DynamicRequest, resp *validator.DynamicResponse) { + resp.Diagnostics.AddAttributeWarning(req.Path, "New Warning Summary", "New Warning Details") + resp.Diagnostics.AddAttributeError(req.Path, "New Error Summary", "New Error Details") + }, + }, + }, + }, + request: ValidateAttributeRequest{ + AttributePath: path.Root("test"), + AttributeConfig: types.DynamicValue(types.StringValue("test")), + }, + response: &ValidateAttributeResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewAttributeWarningDiagnostic( + path.Root("other"), + "Existing Warning Summary", + "Existing Warning Details", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("other"), + "Existing Error Summary", + "Existing Error Details", + ), + }, + }, + expected: &ValidateAttributeResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewAttributeWarningDiagnostic( + path.Root("other"), + "Existing Warning Summary", + "Existing Warning Details", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("other"), + "Existing Error Summary", + "Existing Error Details", + ), + diag.NewAttributeWarningDiagnostic( + path.Root("test"), + "New Warning Summary", + "New Warning Details", + ), + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "New Error Summary", + "New Error Details", + ), + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + AttributeValidateDynamic(context.Background(), testCase.attribute, testCase.request, testCase.response) + + if diff := cmp.Diff(testCase.response, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} func TestNestedAttributeObjectValidateObject(t *testing.T) { t.Parallel() diff --git a/internal/fwserver/server_planresourcechange.go b/internal/fwserver/server_planresourcechange.go index b183dcf3c..c0df76647 100644 --- a/internal/fwserver/server_planresourcechange.go +++ b/internal/fwserver/server_planresourcechange.go @@ -309,6 +309,32 @@ func MarkComputedNilsAsUnknown(ctx context.Context, config tftypes.Value, resour return val, nil } + attribute, err := resourceSchema.AttributeAtTerraformPath(ctx, path) + + if err != nil { + if errors.Is(err, fwschema.ErrPathInsideAtomicAttribute) { + // ignore attributes/elements inside schema.Attributes, they have no schema of their own + logging.FrameworkTrace(ctx, "attribute is a non-schema attribute, not marking unknown") + return val, nil + } + + if errors.Is(err, fwschema.ErrPathIsBlock) { + // ignore blocks, they do not have a computed field + logging.FrameworkTrace(ctx, "attribute is a block, not marking unknown") + return val, nil + } + + if errors.Is(err, fwschema.ErrPathInsideDynamicAttribute) { + // ignore attributes/elements inside schema.DynamicAttribute, they have no schema of their own + logging.FrameworkTrace(ctx, "attribute is inside of a dynamic attribute, not marking unknown") + return val, nil + } + + logging.FrameworkError(ctx, "couldn't find attribute in resource schema") + + return tftypes.Value{}, fmt.Errorf("couldn't find attribute in resource schema: %w", err) + } + configValIface, _, err := tftypes.WalkAttributePath(config, path) if err != nil && err != tftypes.ErrInvalidStep { @@ -331,26 +357,6 @@ func MarkComputedNilsAsUnknown(ctx context.Context, config tftypes.Value, resour return val, nil } - attribute, err := resourceSchema.AttributeAtTerraformPath(ctx, path) - - if err != nil { - if errors.Is(err, fwschema.ErrPathInsideAtomicAttribute) { - // ignore attributes/elements inside schema.Attributes, they have no schema of their own - logging.FrameworkTrace(ctx, "attribute is a non-schema attribute, not marking unknown") - return val, nil - } - - if errors.Is(err, fwschema.ErrPathIsBlock) { - // ignore blocks, they do not have a computed field - logging.FrameworkTrace(ctx, "attribute is a block, not marking unknown") - return val, nil - } - - logging.FrameworkError(ctx, "couldn't find attribute in resource schema") - - return tftypes.Value{}, fmt.Errorf("couldn't find attribute in resource schema: %w", err) - } - if !attribute.IsComputed() { logging.FrameworkTrace(ctx, "attribute is not computed in schema, not marking unknown") @@ -394,6 +400,10 @@ func MarkComputedNilsAsUnknown(ctx context.Context, config tftypes.Value, resour if a.StringDefaultValue() != nil { return val, nil } + case fwschema.AttributeWithDynamicDefaultValue: + if a.DynamicDefaultValue() != nil { + return val, nil + } } logging.FrameworkDebug(ctx, "marking computed attribute that is null in the config as unknown") diff --git a/internal/fwserver/server_planresourcechange_test.go b/internal/fwserver/server_planresourcechange_test.go index 7fc0588a4..b511face5 100644 --- a/internal/fwserver/server_planresourcechange_test.go +++ b/internal/fwserver/server_planresourcechange_test.go @@ -25,6 +25,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/dynamicdefault" "github.com/hashicorp/terraform-plugin-framework/resource/schema/float64default" "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64default" "github.com/hashicorp/terraform-plugin-framework/resource/schema/listdefault" @@ -65,6 +66,38 @@ func TestMarkComputedNilsAsUnknown(t *testing.T) { Optional: true, Computed: true, }, + // values should be left alone + "dynamic-value": schema.DynamicAttribute{ + Required: true, + }, + // nil, uncomputed values should be left alone + "dynamic-nil": schema.DynamicAttribute{ + Optional: true, + }, + // nil computed values should be turned into unknown + "dynamic-nil-computed": schema.DynamicAttribute{ + Computed: true, + }, + // underlying nil computed values should be turned into unknown, preserving the original type + "dynamic-underlying-string-nil-computed": schema.DynamicAttribute{ + Computed: true, + }, + // nil computed values should be turned into unknown + "dynamic-nil-optional-computed": schema.DynamicAttribute{ + Optional: true, + Computed: true, + }, + // non-nil computed values should be left alone + "dynamic-value-optional-computed": schema.DynamicAttribute{ + Optional: true, + Computed: true, + }, + // non-nil computed values should be left alone + // each element of this dynamic value will be visited, then skipped + "dynamic-value-with-underlying-list-optional-computed": schema.DynamicAttribute{ + Optional: true, + Computed: true, + }, // nil objects should be unknown "object-nil-optional-computed": schema.ObjectAttribute{ AttributeTypes: map[string]attr.Type{ @@ -155,11 +188,26 @@ func TestMarkComputedNilsAsUnknown(t *testing.T) { }, } input := tftypes.NewValue(s.Type().TerraformType(context.Background()), map[string]tftypes.Value{ - "string-value": tftypes.NewValue(tftypes.String, "hello, world"), - "string-nil": tftypes.NewValue(tftypes.String, nil), - "string-nil-computed": tftypes.NewValue(tftypes.String, nil), - "string-nil-optional-computed": tftypes.NewValue(tftypes.String, nil), - "string-value-optional-computed": tftypes.NewValue(tftypes.String, "hello, world"), + "string-value": tftypes.NewValue(tftypes.String, "hello, world"), + "string-nil": tftypes.NewValue(tftypes.String, nil), + "string-nil-computed": tftypes.NewValue(tftypes.String, nil), + "string-nil-optional-computed": tftypes.NewValue(tftypes.String, nil), + "string-value-optional-computed": tftypes.NewValue(tftypes.String, "hello, world"), + "dynamic-value": tftypes.NewValue(tftypes.String, "hello, world"), + "dynamic-nil": tftypes.NewValue(tftypes.DynamicPseudoType, nil), + "dynamic-nil-computed": tftypes.NewValue(tftypes.DynamicPseudoType, nil), + "dynamic-underlying-string-nil-computed": tftypes.NewValue(tftypes.String, nil), + "dynamic-nil-optional-computed": tftypes.NewValue(tftypes.DynamicPseudoType, nil), + "dynamic-value-optional-computed": tftypes.NewValue(tftypes.String, "hello, world"), + "dynamic-value-with-underlying-list-optional-computed": tftypes.NewValue( + tftypes.List{ + ElementType: tftypes.Bool, + }, + []tftypes.Value{ + tftypes.NewValue(tftypes.Bool, true), + tftypes.NewValue(tftypes.Bool, false), + }, + ), "object-nil-optional-computed": tftypes.NewValue(tftypes.Object{ AttributeTypes: map[string]tftypes.Type{ "string-nil": tftypes.String, @@ -218,11 +266,26 @@ func TestMarkComputedNilsAsUnknown(t *testing.T) { }), }) expected := tftypes.NewValue(s.Type().TerraformType(context.Background()), map[string]tftypes.Value{ - "string-value": tftypes.NewValue(tftypes.String, "hello, world"), - "string-nil": tftypes.NewValue(tftypes.String, nil), - "string-nil-computed": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), - "string-nil-optional-computed": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), - "string-value-optional-computed": tftypes.NewValue(tftypes.String, "hello, world"), + "string-value": tftypes.NewValue(tftypes.String, "hello, world"), + "string-nil": tftypes.NewValue(tftypes.String, nil), + "string-nil-computed": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "string-nil-optional-computed": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), + "string-value-optional-computed": tftypes.NewValue(tftypes.String, "hello, world"), + "dynamic-value": tftypes.NewValue(tftypes.String, "hello, world"), + "dynamic-nil": tftypes.NewValue(tftypes.DynamicPseudoType, nil), + "dynamic-nil-computed": tftypes.NewValue(tftypes.DynamicPseudoType, tftypes.UnknownValue), + "dynamic-underlying-string-nil-computed": tftypes.NewValue(tftypes.String, tftypes.UnknownValue), // Preserves the underlying string type + "dynamic-nil-optional-computed": tftypes.NewValue(tftypes.DynamicPseudoType, tftypes.UnknownValue), + "dynamic-value-optional-computed": tftypes.NewValue(tftypes.String, "hello, world"), + "dynamic-value-with-underlying-list-optional-computed": tftypes.NewValue( + tftypes.List{ + ElementType: tftypes.Bool, + }, + []tftypes.Value{ + tftypes.NewValue(tftypes.Bool, true), + tftypes.NewValue(tftypes.Bool, false), + }, + ), "object-nil-optional-computed": tftypes.NewValue(tftypes.Object{ AttributeTypes: map[string]tftypes.Type{ "string-nil": tftypes.String, @@ -376,6 +439,7 @@ func TestServerPlanResourceChange(t *testing.T) { "test_computed_set": tftypes.Set{ElementType: tftypes.String}, "test_computed_string": tftypes.String, "test_computed_string_custom_type": tftypes.String, + "test_computed_dynamic": tftypes.DynamicPseudoType, "test_computed_nested_list": tftypes.List{ElementType: tftypes.Object{AttributeTypes: map[string]tftypes.Type{"string_attribute": tftypes.String}}}, "test_computed_nested_list_attribute": tftypes.List{ElementType: tftypes.Object{AttributeTypes: map[string]tftypes.Type{"string_attribute": tftypes.String}}}, "test_computed_nested_map": tftypes.Map{ElementType: tftypes.Object{AttributeTypes: map[string]tftypes.Type{"string_attribute": tftypes.String}}}, @@ -394,6 +458,7 @@ func TestServerPlanResourceChange(t *testing.T) { "test_configured_set": tftypes.Set{ElementType: tftypes.String}, "test_configured_string": tftypes.String, "test_configured_string_custom_type": tftypes.String, + "test_configured_dynamic": tftypes.DynamicPseudoType, "test_configured_nested_list": tftypes.List{ ElementType: tftypes.Object{ AttributeTypes: map[string]tftypes.Type{ @@ -534,6 +599,10 @@ func TestServerPlanResourceChange(t *testing.T) { CustomType: testtypes.StringTypeWithSemanticEquals{}, Default: stringdefault.StaticString("one"), }, + "test_computed_dynamic": schema.DynamicAttribute{ + Computed: true, + Default: dynamicdefault.StaticValue(types.DynamicValue(types.StringValue("hello world!"))), + }, "test_computed_nested_list": schema.ListAttribute{ Computed: true, ElementType: types.ObjectType{ @@ -725,6 +794,11 @@ func TestServerPlanResourceChange(t *testing.T) { CustomType: testtypes.StringTypeWithSemanticEquals{}, Default: stringdefault.StaticString("one"), }, + "test_configured_dynamic": schema.DynamicAttribute{ + Optional: true, + Computed: true, + Default: dynamicdefault.StaticValue(types.DynamicValue(types.StringValue("hello world!"))), + }, "test_configured_nested_list": schema.ListNestedAttribute{ Optional: true, Computed: true, @@ -1273,6 +1347,7 @@ func TestServerPlanResourceChange(t *testing.T) { "test_computed_set": tftypes.NewValue(tftypes.Set{ElementType: tftypes.String}, nil), "test_computed_string": tftypes.NewValue(tftypes.String, nil), "test_computed_string_custom_type": tftypes.NewValue(tftypes.String, nil), + "test_computed_dynamic": tftypes.NewValue(tftypes.DynamicPseudoType, nil), "test_computed_nested_list": tftypes.NewValue( tftypes.List{ ElementType: tftypes.Object{ @@ -1414,6 +1489,7 @@ func TestServerPlanResourceChange(t *testing.T) { ), "test_configured_string": tftypes.NewValue(tftypes.String, "config-value"), "test_configured_string_custom_type": tftypes.NewValue(tftypes.String, "config-value"), + "test_configured_dynamic": tftypes.NewValue(tftypes.String, "config-value"), "test_configured_nested_list": tftypes.NewValue( tftypes.List{ ElementType: tftypes.Object{ @@ -1645,6 +1721,7 @@ func TestServerPlanResourceChange(t *testing.T) { "test_computed_set": tftypes.NewValue(tftypes.Set{ElementType: tftypes.String}, nil), "test_computed_string": tftypes.NewValue(tftypes.String, nil), "test_computed_string_custom_type": tftypes.NewValue(tftypes.String, nil), + "test_computed_dynamic": tftypes.NewValue(tftypes.DynamicPseudoType, nil), "test_computed_nested_list": tftypes.NewValue( tftypes.List{ ElementType: tftypes.Object{ @@ -1786,6 +1863,7 @@ func TestServerPlanResourceChange(t *testing.T) { ), "test_configured_string": tftypes.NewValue(tftypes.String, "config-value"), "test_configured_string_custom_type": tftypes.NewValue(tftypes.String, "config-value"), + "test_configured_dynamic": tftypes.NewValue(tftypes.String, "config-value"), "test_configured_nested_list": tftypes.NewValue( tftypes.List{ ElementType: tftypes.Object{ @@ -2022,6 +2100,7 @@ func TestServerPlanResourceChange(t *testing.T) { "test_computed_set": tftypes.NewValue(tftypes.Set{ElementType: tftypes.String}, []tftypes.Value{tftypes.NewValue(tftypes.String, "default")}), "test_computed_string": tftypes.NewValue(tftypes.String, "one"), "test_computed_string_custom_type": tftypes.NewValue(tftypes.String, "one"), + "test_computed_dynamic": tftypes.NewValue(tftypes.String, "hello world!"), "test_computed_nested_list": tftypes.NewValue( tftypes.List{ ElementType: tftypes.Object{ @@ -2198,6 +2277,7 @@ func TestServerPlanResourceChange(t *testing.T) { ), "test_configured_string": tftypes.NewValue(tftypes.String, "config-value"), "test_configured_string_custom_type": tftypes.NewValue(tftypes.String, "config-value"), + "test_configured_dynamic": tftypes.NewValue(tftypes.String, "config-value"), "test_configured_nested_list": tftypes.NewValue( tftypes.List{ ElementType: tftypes.Object{ @@ -3512,6 +3592,7 @@ func TestServerPlanResourceChange(t *testing.T) { "test_computed_set": tftypes.NewValue(tftypes.Set{ElementType: tftypes.String}, nil), "test_computed_string": tftypes.NewValue(tftypes.String, nil), "test_computed_string_custom_type": tftypes.NewValue(tftypes.String, nil), + "test_computed_dynamic": tftypes.NewValue(tftypes.DynamicPseudoType, nil), "test_computed_nested_list": tftypes.NewValue( tftypes.List{ ElementType: tftypes.Object{ @@ -3653,6 +3734,7 @@ func TestServerPlanResourceChange(t *testing.T) { ), "test_configured_string": tftypes.NewValue(tftypes.String, "config-value"), "test_configured_string_custom_type": tftypes.NewValue(tftypes.String, "config-value"), + "test_configured_dynamic": tftypes.NewValue(tftypes.String, "config-value"), "test_configured_nested_list": tftypes.NewValue( tftypes.List{ ElementType: tftypes.Object{ @@ -3886,6 +3968,7 @@ func TestServerPlanResourceChange(t *testing.T) { "test_computed_set": tftypes.NewValue(tftypes.Set{ElementType: tftypes.String}, nil), "test_computed_string": tftypes.NewValue(tftypes.String, nil), "test_computed_string_custom_type": tftypes.NewValue(tftypes.String, nil), + "test_computed_dynamic": tftypes.NewValue(tftypes.DynamicPseudoType, nil), "test_computed_nested_list": tftypes.NewValue( tftypes.List{ ElementType: tftypes.Object{ @@ -4027,6 +4110,7 @@ func TestServerPlanResourceChange(t *testing.T) { ), "test_configured_string": tftypes.NewValue(tftypes.String, "config-value"), "test_configured_string_custom_type": tftypes.NewValue(tftypes.String, "config-value"), + "test_configured_dynamic": tftypes.NewValue(tftypes.String, "config-value"), "test_configured_nested_list": tftypes.NewValue( tftypes.List{ ElementType: tftypes.Object{ @@ -4260,6 +4344,7 @@ func TestServerPlanResourceChange(t *testing.T) { "test_computed_set": tftypes.NewValue(tftypes.Set{ElementType: tftypes.String}, []tftypes.Value{tftypes.NewValue(tftypes.String, "prior-state")}), "test_computed_string": tftypes.NewValue(tftypes.String, "two"), "test_computed_string_custom_type": tftypes.NewValue(tftypes.String, "two"), + "test_computed_dynamic": tftypes.NewValue(tftypes.String, "two"), "test_computed_nested_list": tftypes.NewValue( tftypes.List{ ElementType: tftypes.Object{ @@ -4436,6 +4521,7 @@ func TestServerPlanResourceChange(t *testing.T) { ), "test_configured_string": tftypes.NewValue(tftypes.String, "state-value"), "test_configured_string_custom_type": tftypes.NewValue(tftypes.String, "state-value"), + "test_configured_dynamic": tftypes.NewValue(tftypes.String, "state-value"), "test_configured_nested_list": tftypes.NewValue( tftypes.List{ ElementType: tftypes.Object{ @@ -4687,6 +4773,7 @@ func TestServerPlanResourceChange(t *testing.T) { "test_computed_set": tftypes.NewValue(tftypes.Set{ElementType: tftypes.String}, []tftypes.Value{tftypes.NewValue(tftypes.String, "default")}), "test_computed_string": tftypes.NewValue(tftypes.String, "one"), "test_computed_string_custom_type": tftypes.NewValue(tftypes.String, "one"), + "test_computed_dynamic": tftypes.NewValue(tftypes.String, "hello world!"), "test_computed_nested_list": tftypes.NewValue( tftypes.List{ ElementType: tftypes.Object{ @@ -4863,6 +4950,7 @@ func TestServerPlanResourceChange(t *testing.T) { ), "test_configured_string": tftypes.NewValue(tftypes.String, "config-value"), "test_configured_string_custom_type": tftypes.NewValue(tftypes.String, "config-value"), + "test_configured_dynamic": tftypes.NewValue(tftypes.String, "config-value"), "test_configured_nested_list": tftypes.NewValue( tftypes.List{ ElementType: tftypes.Object{ diff --git a/internal/fwtype/doc.go b/internal/fwtype/doc.go new file mode 100644 index 000000000..3cd75b75e --- /dev/null +++ b/internal/fwtype/doc.go @@ -0,0 +1,5 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +// Package fwtype implements shared logic for interacting with the framework type system. +package fwtype diff --git a/internal/fwtype/static_collection_validation.go b/internal/fwtype/static_collection_validation.go new file mode 100644 index 000000000..2a91e2fbb --- /dev/null +++ b/internal/fwtype/static_collection_validation.go @@ -0,0 +1,142 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package fwtype + +import ( + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" +) + +// ContainsCollectionWithDynamic will return true if an attr.Type is a complex type that either is or contains any +// collection types with dynamic types, which are not supported by the framework type system. Primitives, invalid +// types (missingType), or nil will return false. +// +// Unsupported collection types include: +// - Lists that contain a dynamic type +// - Maps that contain a dynamic type +// - Sets that contain a dynamic type +func ContainsCollectionWithDynamic(typ attr.Type) bool { + switch attrType := typ.(type) { + // We haven't run into a collection type yet, so it's valid for this to be a dynamic type + case basetypes.DynamicTypable: + return false + // Lists, maps, sets + case attr.TypeWithElementType: + // We found a collection, need to ensure there are no dynamics from this point on. + return containsDynamic(attrType.ElementType()) + // Tuples + case attr.TypeWithElementTypes: + for _, elemType := range attrType.ElementTypes() { + hasDynamic := ContainsCollectionWithDynamic(elemType) + if hasDynamic { + return true + } + } + return false + // Objects + case attr.TypeWithAttributeTypes: + for _, objAttrType := range attrType.AttributeTypes() { + hasDynamic := ContainsCollectionWithDynamic(objAttrType) + if hasDynamic { + return true + } + } + return false + // Primitives, missing types, etc. + default: + return false + } +} + +// containsDynamic will return true if `typ` is a dynamic type or has any nested types that contain a dynamic type. +func containsDynamic(typ attr.Type) bool { + switch attrType := typ.(type) { + // Found a dynamic! + case basetypes.DynamicTypable: + return true + // Lists, maps, sets + case attr.TypeWithElementType: + return containsDynamic(attrType.ElementType()) + // Tuples + case attr.TypeWithElementTypes: + for _, elemType := range attrType.ElementTypes() { + hasDynamic := containsDynamic(elemType) + if hasDynamic { + return true + } + } + return false + // Objects + case attr.TypeWithAttributeTypes: + for _, objAttrType := range attrType.AttributeTypes() { + hasDynamic := containsDynamic(objAttrType) + if hasDynamic { + return true + } + } + return false + // Primitives, missing types, etc. + default: + return false + } +} + +func AttributeCollectionWithDynamicTypeDiag(attributePath path.Path) diag.Diagnostic { + return diag.NewErrorDiagnostic( + "Invalid Schema Implementation", + "When validating the schema, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("%q is an attribute that contains a collection type with a nested dynamic type.\n\n", attributePath)+ + "Dynamic types inside of collections are not currently supported in terraform-plugin-framework. "+ + fmt.Sprintf("If underlying dynamic values are required, replace the %q attribute definition with DynamicAttribute instead.", attributePath), + ) +} + +func BlockCollectionWithDynamicTypeDiag(attributePath path.Path) diag.Diagnostic { + return diag.NewErrorDiagnostic( + "Invalid Schema Implementation", + "When validating the schema, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("%q is a block that contains a collection type with a nested dynamic type.\n\n", attributePath)+ + "Dynamic types inside of collections are not currently supported in terraform-plugin-framework. "+ + fmt.Sprintf("If underlying dynamic values are required, replace the %q block definition with a DynamicAttribute.", attributePath), + ) +} + +func ParameterCollectionWithDynamicTypeDiag(argument int64, name string) diag.Diagnostic { + return diag.NewErrorDiagnostic( + "Invalid Function Definition", + "When validating the function definition, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("Parameter %q at position %d contains a collection type with a nested dynamic type.\n\n", name, argument)+ + "Dynamic types inside of collections are not currently supported in terraform-plugin-framework. "+ + fmt.Sprintf("If underlying dynamic values are required, replace the %q parameter definition with DynamicParameter instead.", name), + ) +} + +func VariadicParameterCollectionWithDynamicTypeDiag(name string) diag.Diagnostic { + return diag.NewErrorDiagnostic( + "Invalid Function Definition", + "When validating the function definition, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + fmt.Sprintf("Variadic parameter %q contains a collection type with a nested dynamic type.\n\n", name)+ + "Dynamic types inside of collections are not currently supported in terraform-plugin-framework. "+ + "If underlying dynamic values are required, replace the variadic parameter definition with DynamicParameter instead.", + ) +} + +func ReturnCollectionWithDynamicTypeDiag() diag.Diagnostic { + return diag.NewErrorDiagnostic( + "Invalid Function Definition", + "When validating the function definition, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "Return contains a collection type with a nested dynamic type.\n\n"+ + "Dynamic types inside of collections are not currently supported in terraform-plugin-framework. "+ + "If underlying dynamic values are required, replace the return definition with DynamicReturn instead.", + ) +} diff --git a/internal/fwtype/static_collection_validation_test.go b/internal/fwtype/static_collection_validation_test.go new file mode 100644 index 000000000..e7ea5e722 --- /dev/null +++ b/internal/fwtype/static_collection_validation_test.go @@ -0,0 +1,947 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package fwtype_test + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/internal/fwtype" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func TestTypeContainsCollectionWithDynamic(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attrTyp attr.Type + expected bool + }{ + "nil": { + attrTyp: nil, + expected: false, + }, + "dynamic": { + attrTyp: types.DynamicType, + expected: false, + }, + "primitive": { + attrTyp: types.StringType, + expected: false, + }, + "list-missing": { + attrTyp: types.ListType{}, + expected: false, + }, + "list-static": { + attrTyp: types.ListType{ + ElemType: types.StringType, + }, + expected: false, + }, + "list-list-static": { + attrTyp: types.ListType{ + ElemType: types.ListType{ + ElemType: types.StringType, + }, + }, + expected: false, + }, + "list-obj-static": { + attrTyp: types.ListType{ + ElemType: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "bool": types.BoolType, + "float64": types.Float64Type, + }, + }, + }, + expected: false, + }, + "list-tuple-static": { + attrTyp: types.ListType{ + ElemType: types.TupleType{ + ElemTypes: []attr.Type{ + types.BoolType, + types.Float64Type, + }, + }, + }, + expected: false, + }, + "list-dynamic": { + attrTyp: types.ListType{ + ElemType: types.DynamicType, + }, + expected: true, + }, + "list-list-dynamic": { + attrTyp: types.ListType{ + ElemType: types.ListType{ + ElemType: types.DynamicType, + }, + }, + expected: true, + }, + "list-obj-dynamic": { + attrTyp: types.ListType{ + ElemType: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "bool": types.BoolType, + "dynamic": types.DynamicType, + }, + }, + }, + expected: true, + }, + "list-tuple-dynamic": { + attrTyp: types.ListType{ + ElemType: types.TupleType{ + ElemTypes: []attr.Type{ + types.BoolType, + types.DynamicType, + }, + }, + }, + expected: true, + }, + "map-missing": { + attrTyp: types.MapType{}, + expected: false, + }, + "map-static": { + attrTyp: types.MapType{ + ElemType: types.StringType, + }, + expected: false, + }, + "map-map-static": { + attrTyp: types.MapType{ + ElemType: types.MapType{ + ElemType: types.StringType, + }, + }, + expected: false, + }, + "map-obj-static": { + attrTyp: types.MapType{ + ElemType: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "bool": types.BoolType, + "float64": types.Float64Type, + }, + }, + }, + expected: false, + }, + "map-tuple-static": { + attrTyp: types.MapType{ + ElemType: types.TupleType{ + ElemTypes: []attr.Type{ + types.BoolType, + types.Float64Type, + }, + }, + }, + expected: false, + }, + "map-dynamic": { + attrTyp: types.MapType{ + ElemType: types.DynamicType, + }, + expected: true, + }, + "map-map-dynamic": { + attrTyp: types.MapType{ + ElemType: types.MapType{ + ElemType: types.DynamicType, + }, + }, + expected: true, + }, + "map-obj-dynamic": { + attrTyp: types.MapType{ + ElemType: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "bool": types.BoolType, + "dynamic": types.DynamicType, + }, + }, + }, + expected: true, + }, + "map-tuple-dynamic": { + attrTyp: types.MapType{ + ElemType: types.TupleType{ + ElemTypes: []attr.Type{ + types.BoolType, + types.DynamicType, + }, + }, + }, + expected: true, + }, + "obj-list-missing": { + attrTyp: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "list": types.ListType{}, + }, + }, + expected: false, + }, + "obj-list-static": { + attrTyp: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "list": types.ListType{ + ElemType: types.StringType, + }, + }, + }, + expected: false, + }, + "obj-list-list-static": { + attrTyp: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "list": types.ListType{ + ElemType: types.ListType{ + ElemType: types.StringType, + }, + }, + }, + }, + expected: false, + }, + "obj-list-obj-static": { + attrTyp: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "list": types.ListType{ + ElemType: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "bool": types.BoolType, + "float64": types.Float64Type, + }, + }, + }, + }, + }, + expected: false, + }, + "obj-list-tuple-static": { + attrTyp: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "list": types.ListType{ + ElemType: types.TupleType{ + ElemTypes: []attr.Type{ + types.BoolType, + types.Float64Type, + }, + }, + }, + }, + }, + expected: false, + }, + "obj-list-dynamic": { + attrTyp: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "list": types.ListType{ + ElemType: types.DynamicType, + }, + }, + }, + expected: true, + }, + "obj-list-list-dynamic": { + attrTyp: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "list": types.ListType{ + ElemType: types.ListType{ + ElemType: types.DynamicType, + }, + }, + }, + }, + expected: true, + }, + "obj-list-obj-dynamic": { + attrTyp: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "list": types.ListType{ + ElemType: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "bool": types.BoolType, + "dynamic": types.DynamicType, + }, + }, + }, + }, + }, + expected: true, + }, + "obj-list-tuple-dynamic": { + attrTyp: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "list": types.ListType{ + ElemType: types.TupleType{ + ElemTypes: []attr.Type{ + types.BoolType, + types.DynamicType, + }, + }, + }, + }, + }, + expected: true, + }, + "obj-map-missing": { + attrTyp: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "map": types.MapType{}, + }, + }, + expected: false, + }, + "obj-map-static": { + attrTyp: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "map": types.MapType{ + ElemType: types.StringType, + }, + }, + }, + expected: false, + }, + "obj-map-map-static": { + attrTyp: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "map": types.MapType{ + ElemType: types.MapType{ + ElemType: types.StringType, + }, + }, + }, + }, + expected: false, + }, + "obj-map-obj-static": { + attrTyp: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "map": types.MapType{ + ElemType: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "bool": types.BoolType, + "float64": types.Float64Type, + }, + }, + }, + }, + }, + expected: false, + }, + "obj-map-tuple-static": { + attrTyp: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "map": types.MapType{ + ElemType: types.TupleType{ + ElemTypes: []attr.Type{ + types.BoolType, + types.Float64Type, + }, + }, + }, + }, + }, + expected: false, + }, + "obj-map-dynamic": { + attrTyp: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "map": types.MapType{ + ElemType: types.DynamicType, + }, + }, + }, + expected: true, + }, + "obj-map-map-dynamic": { + attrTyp: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "map": types.MapType{ + ElemType: types.MapType{ + ElemType: types.DynamicType, + }, + }, + }, + }, + expected: true, + }, + "obj-map-obj-dynamic": { + attrTyp: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "map": types.MapType{ + ElemType: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "bool": types.BoolType, + "dynamic": types.DynamicType, + }, + }, + }, + }, + }, + expected: true, + }, + "obj-map-tuple-dynamic": { + attrTyp: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "map": types.MapType{ + ElemType: types.TupleType{ + ElemTypes: []attr.Type{ + types.BoolType, + types.DynamicType, + }, + }, + }, + }, + }, + expected: true, + }, + "obj-set-missing": { + attrTyp: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "set": types.SetType{}, + }, + }, + expected: false, + }, + "obj-set-static": { + attrTyp: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "set": types.SetType{ + ElemType: types.StringType, + }, + }, + }, + expected: false, + }, + "obj-set-set-static": { + attrTyp: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "set": types.SetType{ + ElemType: types.SetType{ + ElemType: types.StringType, + }, + }, + }, + }, + expected: false, + }, + "obj-set-obj-static": { + attrTyp: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "set": types.SetType{ + ElemType: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "bool": types.BoolType, + "float64": types.Float64Type, + }, + }, + }, + }, + }, + expected: false, + }, + "obj-set-tuple-static": { + attrTyp: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "set": types.SetType{ + ElemType: types.TupleType{ + ElemTypes: []attr.Type{ + types.BoolType, + types.Float64Type, + }, + }, + }, + }, + }, + expected: false, + }, + "obj-set-dynamic": { + attrTyp: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "set": types.SetType{ + ElemType: types.DynamicType, + }, + }, + }, + expected: true, + }, + "obj-set-set-dynamic": { + attrTyp: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "set": types.SetType{ + ElemType: types.SetType{ + ElemType: types.DynamicType, + }, + }, + }, + }, + expected: true, + }, + "obj-set-obj-dynamic": { + attrTyp: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "set": types.SetType{ + ElemType: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "bool": types.BoolType, + "dynamic": types.DynamicType, + }, + }, + }, + }, + }, + expected: true, + }, + "obj-set-tuple-dynamic": { + attrTyp: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "set": types.SetType{ + ElemType: types.TupleType{ + ElemTypes: []attr.Type{ + types.BoolType, + types.DynamicType, + }, + }, + }, + }, + }, + expected: true, + }, + "tuple-list-missing": { + attrTyp: types.TupleType{ + ElemTypes: []attr.Type{ + types.ListType{}, + }, + }, + expected: false, + }, + "tuple-list-static": { + attrTyp: types.TupleType{ + ElemTypes: []attr.Type{ + types.ListType{ + ElemType: types.StringType, + }, + }, + }, + expected: false, + }, + "tuple-list-list-static": { + attrTyp: types.TupleType{ + ElemTypes: []attr.Type{ + types.ListType{ + ElemType: types.ListType{ + ElemType: types.StringType, + }, + }, + }, + }, + expected: false, + }, + "tuple-list-obj-static": { + attrTyp: types.TupleType{ + ElemTypes: []attr.Type{ + types.ListType{ + ElemType: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "bool": types.BoolType, + "float64": types.Float64Type, + }, + }, + }, + }, + }, + expected: false, + }, + "tuple-list-tuple-static": { + attrTyp: types.TupleType{ + ElemTypes: []attr.Type{ + types.ListType{ + ElemType: types.TupleType{ + ElemTypes: []attr.Type{ + types.BoolType, + types.Float64Type, + }, + }, + }, + }, + }, + expected: false, + }, + "tuple-list-dynamic": { + attrTyp: types.TupleType{ + ElemTypes: []attr.Type{ + types.ListType{ + ElemType: types.DynamicType, + }, + }, + }, + expected: true, + }, + "tuple-list-list-dynamic": { + attrTyp: types.TupleType{ + ElemTypes: []attr.Type{ + types.ListType{ + ElemType: types.ListType{ + ElemType: types.DynamicType, + }, + }, + }, + }, + expected: true, + }, + "tuple-list-obj-dynamic": { + attrTyp: types.TupleType{ + ElemTypes: []attr.Type{ + types.ListType{ + ElemType: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "bool": types.BoolType, + "dynamic": types.DynamicType, + }, + }, + }, + }, + }, + expected: true, + }, + "tuple-list-tuple-dynamic": { + attrTyp: types.TupleType{ + ElemTypes: []attr.Type{ + types.ListType{ + ElemType: types.TupleType{ + ElemTypes: []attr.Type{ + types.BoolType, + types.DynamicType, + }, + }, + }, + }, + }, + expected: true, + }, + "tuple-map-missing": { + attrTyp: types.TupleType{ + ElemTypes: []attr.Type{ + types.MapType{}, + }, + }, + expected: false, + }, + "tuple-map-static": { + attrTyp: types.TupleType{ + ElemTypes: []attr.Type{ + types.MapType{ + ElemType: types.StringType, + }, + }, + }, + expected: false, + }, + "tuple-map-map-static": { + attrTyp: types.TupleType{ + ElemTypes: []attr.Type{ + types.MapType{ + ElemType: types.MapType{ + ElemType: types.StringType, + }, + }, + }, + }, + expected: false, + }, + "tuple-map-obj-static": { + attrTyp: types.TupleType{ + ElemTypes: []attr.Type{ + types.MapType{ + ElemType: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "bool": types.BoolType, + "float64": types.Float64Type, + }, + }, + }, + }, + }, + expected: false, + }, + "tuple-map-tuple-static": { + attrTyp: types.TupleType{ + ElemTypes: []attr.Type{ + types.MapType{ + ElemType: types.TupleType{ + ElemTypes: []attr.Type{ + types.BoolType, + types.Float64Type, + }, + }, + }, + }, + }, + expected: false, + }, + "tuple-map-dynamic": { + attrTyp: types.TupleType{ + ElemTypes: []attr.Type{ + types.MapType{ + ElemType: types.DynamicType, + }, + }, + }, + expected: true, + }, + "tuple-map-map-dynamic": { + attrTyp: types.TupleType{ + ElemTypes: []attr.Type{ + types.MapType{ + ElemType: types.MapType{ + ElemType: types.DynamicType, + }, + }, + }, + }, + expected: true, + }, + "tuple-map-obj-dynamic": { + attrTyp: types.TupleType{ + ElemTypes: []attr.Type{ + types.MapType{ + ElemType: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "bool": types.BoolType, + "dynamic": types.DynamicType, + }, + }, + }, + }, + }, + expected: true, + }, + "tuple-map-tuple-dynamic": { + attrTyp: types.TupleType{ + ElemTypes: []attr.Type{ + types.MapType{ + ElemType: types.TupleType{ + ElemTypes: []attr.Type{ + types.BoolType, + types.DynamicType, + }, + }, + }, + }, + }, + expected: true, + }, + "tuple-set-missing": { + attrTyp: types.TupleType{ + ElemTypes: []attr.Type{ + types.SetType{}, + }, + }, + expected: false, + }, + "tuple-set-static": { + attrTyp: types.TupleType{ + ElemTypes: []attr.Type{ + types.SetType{ + ElemType: types.StringType, + }, + }, + }, + expected: false, + }, + "tuple-set-set-static": { + attrTyp: types.TupleType{ + ElemTypes: []attr.Type{ + types.SetType{ + ElemType: types.SetType{ + ElemType: types.StringType, + }, + }, + }, + }, + expected: false, + }, + "tuple-set-obj-static": { + attrTyp: types.TupleType{ + ElemTypes: []attr.Type{ + types.SetType{ + ElemType: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "bool": types.BoolType, + "float64": types.Float64Type, + }, + }, + }, + }, + }, + expected: false, + }, + "tuple-set-tuple-static": { + attrTyp: types.TupleType{ + ElemTypes: []attr.Type{ + types.SetType{ + ElemType: types.TupleType{ + ElemTypes: []attr.Type{ + types.BoolType, + types.Float64Type, + }, + }, + }, + }, + }, + expected: false, + }, + "tuple-set-dynamic": { + attrTyp: types.TupleType{ + ElemTypes: []attr.Type{ + types.SetType{ + ElemType: types.DynamicType, + }, + }, + }, + expected: true, + }, + "tuple-set-set-dynamic": { + attrTyp: types.TupleType{ + ElemTypes: []attr.Type{ + types.SetType{ + ElemType: types.SetType{ + ElemType: types.DynamicType, + }, + }, + }, + }, + expected: true, + }, + "tuple-set-obj-dynamic": { + attrTyp: types.TupleType{ + ElemTypes: []attr.Type{ + types.SetType{ + ElemType: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "bool": types.BoolType, + "dynamic": types.DynamicType, + }, + }, + }, + }, + }, + expected: true, + }, + "tuple-set-tuple-dynamic": { + attrTyp: types.TupleType{ + ElemTypes: []attr.Type{ + types.SetType{ + ElemType: types.TupleType{ + ElemTypes: []attr.Type{ + types.BoolType, + types.DynamicType, + }, + }, + }, + }, + }, + expected: true, + }, + "set-missing": { + attrTyp: types.SetType{}, + expected: false, + }, + "set-static": { + attrTyp: types.SetType{ + ElemType: types.StringType, + }, + expected: false, + }, + "set-set-static": { + attrTyp: types.SetType{ + ElemType: types.SetType{ + ElemType: types.StringType, + }, + }, + expected: false, + }, + "set-obj-static": { + attrTyp: types.SetType{ + ElemType: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "bool": types.BoolType, + "float64": types.Float64Type, + }, + }, + }, + expected: false, + }, + "set-tuple-static": { + attrTyp: types.SetType{ + ElemType: types.TupleType{ + ElemTypes: []attr.Type{ + types.BoolType, + types.Float64Type, + }, + }, + }, + expected: false, + }, + "set-dynamic": { + attrTyp: types.SetType{ + ElemType: types.DynamicType, + }, + expected: true, + }, + "set-set-dynamic": { + attrTyp: types.SetType{ + ElemType: types.SetType{ + ElemType: types.DynamicType, + }, + }, + expected: true, + }, + "set-obj-dynamic": { + attrTyp: types.SetType{ + ElemType: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "bool": types.BoolType, + "dynamic": types.DynamicType, + }, + }, + }, + expected: true, + }, + "set-tuple-dynamic": { + attrTyp: types.SetType{ + ElemType: types.TupleType{ + ElemTypes: []attr.Type{ + types.BoolType, + types.DynamicType, + }, + }, + }, + expected: true, + }, + } + for name, testCase := range testCases { + name, testCase := name, testCase + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := fwtype.ContainsCollectionWithDynamic(testCase.attrTyp) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/internal/reflect/into.go b/internal/reflect/into.go index 5cc92d3ca..5615b8b87 100644 --- a/internal/reflect/into.go +++ b/internal/reflect/into.go @@ -148,6 +148,20 @@ func BuildValue(ctx context.Context, typ attr.Type, val tftypes.Value, target re return target, diags } + + // Dynamic reflection is currently only supported using an `attr.Value`, which should have happened in logic above. + if typ.TerraformType(ctx).Is(tftypes.DynamicPseudoType) { + diags.AddAttributeError( + path, + "Value Conversion Error", + "An unexpected error was encountered trying to build a value. This is always an error in the provider. Please report the following to the provider developer:\n\n"+ + "Reflection for dynamic types is currently not supported. Use the corresponding `types` package type or a custom type that handles dynamic values.\n\n"+ + fmt.Sprintf("Path: %s\nTarget Type: %s\nSuggested `types` Type: %s", path.String(), target.Type(), reflect.TypeOf(typ.ValueType(ctx))), + ) + + return target, diags + } + // *big.Float and *big.Int are technically pointers, but we want them // handled as numbers if target.Type() == reflect.TypeOf(big.NewFloat(0)) || target.Type() == reflect.TypeOf(big.NewInt(0)) { diff --git a/internal/reflect/into_test.go b/internal/reflect/into_test.go index a0d776347..15e2b1048 100644 --- a/internal/reflect/into_test.go +++ b/internal/reflect/into_test.go @@ -149,6 +149,66 @@ func TestInto_Slices(t *testing.T) { ), }, }, + "dynamic-list-to-go-slice-unsupported": { + typ: types.DynamicType, + value: tftypes.NewValue(tftypes.List{ + ElementType: tftypes.String, + }, []tftypes.Value{ + tftypes.NewValue(tftypes.String, "hello"), + tftypes.NewValue(tftypes.String, "world"), + }), + target: make([]string, 0), + expected: make([]string, 0), + expectedDiags: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Empty(), + "Value Conversion Error", + "An unexpected error was encountered trying to build a value. This is always an error in the provider. Please report the following to the provider developer:\n\n"+ + "Reflection for dynamic types is currently not supported. Use the corresponding `types` package type or a custom type that handles dynamic values.\n\n"+ + "Path: \nTarget Type: []string\nSuggested `types` Type: basetypes.DynamicValue", + ), + }, + }, + "dynamic-set-to-go-slice-unsupported": { + typ: types.DynamicType, + value: tftypes.NewValue(tftypes.Set{ + ElementType: tftypes.String, + }, []tftypes.Value{ + tftypes.NewValue(tftypes.String, "hello"), + tftypes.NewValue(tftypes.String, "world"), + }), + target: make([]string, 0), + expected: make([]string, 0), + expectedDiags: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Empty(), + "Value Conversion Error", + "An unexpected error was encountered trying to build a value. This is always an error in the provider. Please report the following to the provider developer:\n\n"+ + "Reflection for dynamic types is currently not supported. Use the corresponding `types` package type or a custom type that handles dynamic values.\n\n"+ + "Path: \nTarget Type: []string\nSuggested `types` Type: basetypes.DynamicValue", + ), + }, + }, + "dynamic-tuple-to-go-slice-unsupported": { + typ: types.DynamicType, + value: tftypes.NewValue(tftypes.Tuple{ + ElementTypes: []tftypes.Type{tftypes.String, tftypes.String}, + }, []tftypes.Value{ + tftypes.NewValue(tftypes.String, "hello"), + tftypes.NewValue(tftypes.String, "world"), + }), + target: make([]string, 0), + expected: make([]string, 0), + expectedDiags: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Empty(), + "Value Conversion Error", + "An unexpected error was encountered trying to build a value. This is always an error in the provider. Please report the following to the provider developer:\n\n"+ + "Reflection for dynamic types is currently not supported. Use the corresponding `types` package type or a custom type that handles dynamic values.\n\n"+ + "Path: \nTarget Type: []string\nSuggested `types` Type: basetypes.DynamicValue", + ), + }, + }, } for name, testCase := range testCases { name, testCase := name, testCase diff --git a/internal/reflect/outof_test.go b/internal/reflect/outof_test.go index 6661c24c7..ab618f7d0 100644 --- a/internal/reflect/outof_test.go +++ b/internal/reflect/outof_test.go @@ -128,6 +128,43 @@ func TestFromValue_go_types(t *testing.T) { ), }, }, + "go-struct-with-dynamic-values": { + typ: types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "dynamic_attr": types.DynamicType, + "dynamic_list": types.DynamicType, + }, + }, + value: struct { + DynamicAttr types.Dynamic `tfsdk:"dynamic_attr"` + DynamicList types.Dynamic `tfsdk:"dynamic_list"` + }{ + DynamicAttr: types.DynamicValue(types.StringValue("hello world")), + DynamicList: types.DynamicValue(types.ListValueMust( + types.BoolType, + []attr.Value{ + types.BoolValue(true), + types.BoolValue(false), + }, + )), + }, + expected: types.ObjectValueMust( + map[string]attr.Type{ + "dynamic_attr": types.DynamicType, + "dynamic_list": types.DynamicType, + }, + map[string]attr.Value{ + "dynamic_attr": types.DynamicValue(types.StringValue("hello world")), + "dynamic_list": types.DynamicValue(types.ListValueMust( + types.BoolType, + []attr.Value{ + types.BoolValue(true), + types.BoolValue(false), + }, + )), + }, + ), + }, } for name, testCase := range testCases { name, testCase := name, testCase diff --git a/internal/reflect/struct.go b/internal/reflect/struct.go index da646b85d..ab654c834 100644 --- a/internal/reflect/struct.go +++ b/internal/reflect/struct.go @@ -234,8 +234,17 @@ func FromStruct(ctx context.Context, typ attr.TypeWithAttributeTypes, val reflec } } + tfObjTyp := tfObjVal.Type() + + // If the original attribute type is tftypes.DynamicPseudoType, the value could end up being + // a concrete type (like tftypes.String, tftypes.List, etc.). In this scenario, the type used + // to build the final tftypes.Object must stay as tftypes.DynamicPseudoType + if attrTypes[name].TerraformType(ctx).Is(tftypes.DynamicPseudoType) { + tfObjTyp = tftypes.DynamicPseudoType + } + objValues[name] = tfObjVal - objTypes[name] = tfObjVal.Type() + objTypes[name] = tfObjTyp } tfVal := tftypes.NewValue(tftypes.Object{ diff --git a/internal/testing/testdefaults/dynamic.go b/internal/testing/testdefaults/dynamic.go new file mode 100644 index 000000000..5497b91d5 --- /dev/null +++ b/internal/testing/testdefaults/dynamic.go @@ -0,0 +1,49 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package testdefaults + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/defaults" +) + +var _ defaults.Dynamic = Dynamic{} + +// Declarative defaults.Dynamic for unit testing. +type Dynamic struct { + // defaults.Describer interface methods + DescriptionMethod func(context.Context) string + MarkdownDescriptionMethod func(context.Context) string + + // defaults.Dynamic interface methods + DefaultDynamicMethod func(context.Context, defaults.DynamicRequest, *defaults.DynamicResponse) +} + +// Description satisfies the defaults.Describer interface. +func (v Dynamic) Description(ctx context.Context) string { + if v.DescriptionMethod == nil { + return "" + } + + return v.DescriptionMethod(ctx) +} + +// MarkdownDescription satisfies the defaults.Describer interface. +func (v Dynamic) MarkdownDescription(ctx context.Context) string { + if v.MarkdownDescriptionMethod == nil { + return "" + } + + return v.MarkdownDescriptionMethod(ctx) +} + +// DefaultDynamic satisfies the defaults.Dynamic interface. +func (v Dynamic) DefaultDynamic(ctx context.Context, req defaults.DynamicRequest, resp *defaults.DynamicResponse) { + if v.DefaultDynamicMethod == nil { + return + } + + v.DefaultDynamicMethod(ctx, req, resp) +} diff --git a/internal/testing/testplanmodifier/dynamic.go b/internal/testing/testplanmodifier/dynamic.go new file mode 100644 index 000000000..746417f4b --- /dev/null +++ b/internal/testing/testplanmodifier/dynamic.go @@ -0,0 +1,47 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package testplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +var _ planmodifier.Dynamic = &Dynamic{} + +// Declarative planmodifier.Dynamic for unit testing. +type Dynamic struct { + // Dynamic interface methods + DescriptionMethod func(context.Context) string + MarkdownDescriptionMethod func(context.Context) string + PlanModifyDynamicMethod func(context.Context, planmodifier.DynamicRequest, *planmodifier.DynamicResponse) +} + +// Description satisfies the planmodifier.Dynamic interface. +func (v Dynamic) Description(ctx context.Context) string { + if v.DescriptionMethod == nil { + return "" + } + + return v.DescriptionMethod(ctx) +} + +// MarkdownDescription satisfies the planmodifier.Dynamic interface. +func (v Dynamic) MarkdownDescription(ctx context.Context) string { + if v.MarkdownDescriptionMethod == nil { + return "" + } + + return v.MarkdownDescriptionMethod(ctx) +} + +// PlanModify satisfies the planmodifier.Dynamic interface. +func (v Dynamic) PlanModifyDynamic(ctx context.Context, req planmodifier.DynamicRequest, resp *planmodifier.DynamicResponse) { + if v.PlanModifyDynamicMethod == nil { + return + } + + v.PlanModifyDynamicMethod(ctx, req, resp) +} diff --git a/internal/testing/testschema/attributewithdynamicdefault.go b/internal/testing/testschema/attributewithdynamicdefault.go new file mode 100644 index 000000000..d366beb9a --- /dev/null +++ b/internal/testing/testschema/attributewithdynamicdefault.go @@ -0,0 +1,87 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package testschema + +import ( + "github.com/hashicorp/terraform-plugin-go/tftypes" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/defaults" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var _ fwschema.AttributeWithDynamicDefaultValue = AttributeWithDynamicDefaultValue{} + +type AttributeWithDynamicDefaultValue struct { + Computed bool + DeprecationMessage string + Description string + MarkdownDescription string + Optional bool + Required bool + Sensitive bool + Default defaults.Dynamic +} + +// ApplyTerraform5AttributePathStep satisfies the fwschema.Attribute interface. +func (a AttributeWithDynamicDefaultValue) ApplyTerraform5AttributePathStep(step tftypes.AttributePathStep) (any, error) { + return a.GetType().ApplyTerraform5AttributePathStep(step) +} + +// DynamicDefaultValue satisfies the fwxschema.AttributeWithDynamicDefaultValue interface. +func (a AttributeWithDynamicDefaultValue) DynamicDefaultValue() defaults.Dynamic { + return a.Default +} + +// Equal satisfies the fwschema.Attribute interface. +func (a AttributeWithDynamicDefaultValue) Equal(o fwschema.Attribute) bool { + _, ok := o.(AttributeWithDynamicDefaultValue) + + if !ok { + return false + } + + return fwschema.AttributesEqual(a, o) +} + +// GetDeprecationMessage satisfies the fwschema.Attribute interface. +func (a AttributeWithDynamicDefaultValue) GetDeprecationMessage() string { + return a.DeprecationMessage +} + +// GetDescription satisfies the fwschema.Attribute interface. +func (a AttributeWithDynamicDefaultValue) GetDescription() string { + return a.Description +} + +// GetMarkdownDescription satisfies the fwschema.Attribute interface. +func (a AttributeWithDynamicDefaultValue) GetMarkdownDescription() string { + return a.MarkdownDescription +} + +// GetType satisfies the fwschema.Attribute interface. +func (a AttributeWithDynamicDefaultValue) GetType() attr.Type { + return types.DynamicType +} + +// IsComputed satisfies the fwschema.Attribute interface. +func (a AttributeWithDynamicDefaultValue) IsComputed() bool { + return a.Computed +} + +// IsOptional satisfies the fwschema.Attribute interface. +func (a AttributeWithDynamicDefaultValue) IsOptional() bool { + return a.Optional +} + +// IsRequired satisfies the fwschema.Attribute interface. +func (a AttributeWithDynamicDefaultValue) IsRequired() bool { + return a.Required +} + +// IsSensitive satisfies the fwschema.Attribute interface. +func (a AttributeWithDynamicDefaultValue) IsSensitive() bool { + return a.Sensitive +} diff --git a/internal/testing/testschema/attributewithdynamicplanmodifiers.go b/internal/testing/testschema/attributewithdynamicplanmodifiers.go new file mode 100644 index 000000000..abb7ca6bf --- /dev/null +++ b/internal/testing/testschema/attributewithdynamicplanmodifiers.go @@ -0,0 +1,87 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package testschema + +import ( + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschema/fwxschema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +var _ fwxschema.AttributeWithDynamicPlanModifiers = AttributeWithDynamicPlanModifiers{} + +type AttributeWithDynamicPlanModifiers struct { + Computed bool + DeprecationMessage string + Description string + MarkdownDescription string + Optional bool + Required bool + Sensitive bool + PlanModifiers []planmodifier.Dynamic +} + +// ApplyTerraform5AttributePathStep satisfies the fwschema.Attribute interface. +func (a AttributeWithDynamicPlanModifiers) ApplyTerraform5AttributePathStep(step tftypes.AttributePathStep) (any, error) { + return a.GetType().ApplyTerraform5AttributePathStep(step) +} + +// Equal satisfies the fwschema.Attribute interface. +func (a AttributeWithDynamicPlanModifiers) Equal(o fwschema.Attribute) bool { + _, ok := o.(AttributeWithDynamicPlanModifiers) + + if !ok { + return false + } + + return fwschema.AttributesEqual(a, o) +} + +// GetDeprecationMessage satisfies the fwschema.Attribute interface. +func (a AttributeWithDynamicPlanModifiers) GetDeprecationMessage() string { + return a.DeprecationMessage +} + +// GetDescription satisfies the fwschema.Attribute interface. +func (a AttributeWithDynamicPlanModifiers) GetDescription() string { + return a.Description +} + +// GetMarkdownDescription satisfies the fwschema.Attribute interface. +func (a AttributeWithDynamicPlanModifiers) GetMarkdownDescription() string { + return a.MarkdownDescription +} + +// GetType satisfies the fwschema.Attribute interface. +func (a AttributeWithDynamicPlanModifiers) GetType() attr.Type { + return types.DynamicType +} + +// IsComputed satisfies the fwschema.Attribute interface. +func (a AttributeWithDynamicPlanModifiers) IsComputed() bool { + return a.Computed +} + +// IsOptional satisfies the fwschema.Attribute interface. +func (a AttributeWithDynamicPlanModifiers) IsOptional() bool { + return a.Optional +} + +// IsRequired satisfies the fwschema.Attribute interface. +func (a AttributeWithDynamicPlanModifiers) IsRequired() bool { + return a.Required +} + +// IsSensitive satisfies the fwschema.Attribute interface. +func (a AttributeWithDynamicPlanModifiers) IsSensitive() bool { + return a.Sensitive +} + +// DynamicPlanModifiers satisfies the fwxschema.AttributeWithDynamicPlanModifiers interface. +func (a AttributeWithDynamicPlanModifiers) DynamicPlanModifiers() []planmodifier.Dynamic { + return a.PlanModifiers +} diff --git a/internal/testing/testschema/attributewithdynamicvalidators.go b/internal/testing/testschema/attributewithdynamicvalidators.go new file mode 100644 index 000000000..1fe086775 --- /dev/null +++ b/internal/testing/testschema/attributewithdynamicvalidators.go @@ -0,0 +1,87 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package testschema + +import ( + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschema/fwxschema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +var _ fwxschema.AttributeWithDynamicValidators = AttributeWithDynamicValidators{} + +type AttributeWithDynamicValidators struct { + Computed bool + DeprecationMessage string + Description string + MarkdownDescription string + Optional bool + Required bool + Sensitive bool + Validators []validator.Dynamic +} + +// ApplyTerraform5AttributePathStep satisfies the fwschema.Attribute interface. +func (a AttributeWithDynamicValidators) ApplyTerraform5AttributePathStep(step tftypes.AttributePathStep) (any, error) { + return a.GetType().ApplyTerraform5AttributePathStep(step) +} + +// Equal satisfies the fwschema.Attribute interface. +func (a AttributeWithDynamicValidators) Equal(o fwschema.Attribute) bool { + _, ok := o.(AttributeWithDynamicValidators) + + if !ok { + return false + } + + return fwschema.AttributesEqual(a, o) +} + +// GetDeprecationMessage satisfies the fwschema.Attribute interface. +func (a AttributeWithDynamicValidators) GetDeprecationMessage() string { + return a.DeprecationMessage +} + +// GetDescription satisfies the fwschema.Attribute interface. +func (a AttributeWithDynamicValidators) GetDescription() string { + return a.Description +} + +// GetMarkdownDescription satisfies the fwschema.Attribute interface. +func (a AttributeWithDynamicValidators) GetMarkdownDescription() string { + return a.MarkdownDescription +} + +// GetType satisfies the fwschema.Attribute interface. +func (a AttributeWithDynamicValidators) GetType() attr.Type { + return types.DynamicType +} + +// IsComputed satisfies the fwschema.Attribute interface. +func (a AttributeWithDynamicValidators) IsComputed() bool { + return a.Computed +} + +// IsOptional satisfies the fwschema.Attribute interface. +func (a AttributeWithDynamicValidators) IsOptional() bool { + return a.Optional +} + +// IsRequired satisfies the fwschema.Attribute interface. +func (a AttributeWithDynamicValidators) IsRequired() bool { + return a.Required +} + +// IsSensitive satisfies the fwschema.Attribute interface. +func (a AttributeWithDynamicValidators) IsSensitive() bool { + return a.Sensitive +} + +// DynamicValidators satisfies the fwxschema.AttributeWithDynamicValidators interface. +func (a AttributeWithDynamicValidators) DynamicValidators() []validator.Dynamic { + return a.Validators +} diff --git a/internal/testing/testtypes/dynamic.go b/internal/testing/testtypes/dynamic.go new file mode 100644 index 000000000..145594eb9 --- /dev/null +++ b/internal/testing/testtypes/dynamic.go @@ -0,0 +1,42 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package testtypes + +import ( + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" +) + +var ( + _ basetypes.DynamicTypable = DynamicType{} + _ basetypes.DynamicValuable = DynamicValue{} +) + +type DynamicType struct { + basetypes.DynamicType +} + +func (t DynamicType) Equal(o attr.Type) bool { + other, ok := o.(DynamicType) + + if !ok { + return false + } + + return t.DynamicType.Equal(other.DynamicType) +} + +type DynamicValue struct { + basetypes.DynamicValue +} + +func (v DynamicValue) Equal(o attr.Value) bool { + other, ok := o.(DynamicValue) + + if !ok { + return false + } + + return v.DynamicValue.Equal(other.DynamicValue) +} diff --git a/internal/testing/testtypes/dynamicwithsemanticequals.go b/internal/testing/testtypes/dynamicwithsemanticequals.go new file mode 100644 index 000000000..b1a033564 --- /dev/null +++ b/internal/testing/testtypes/dynamicwithsemanticequals.go @@ -0,0 +1,116 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package testtypes + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +var ( + _ basetypes.DynamicTypable = DynamicTypeWithSemanticEquals{} + _ basetypes.DynamicValuableWithSemanticEquals = DynamicValueWithSemanticEquals{} +) + +// DynamicTypeWithSemanticEquals is a DynamicType associated with +// DynamicValueWithSemanticEquals, which implements semantic equality logic that +// returns the SemanticEquals boolean for testing. +type DynamicTypeWithSemanticEquals struct { + basetypes.DynamicType + + SemanticEquals bool + SemanticEqualsDiagnostics diag.Diagnostics +} + +func (t DynamicTypeWithSemanticEquals) Equal(o attr.Type) bool { + other, ok := o.(DynamicTypeWithSemanticEquals) + + if !ok { + return false + } + + if t.SemanticEquals != other.SemanticEquals { + return false + } + + return t.DynamicType.Equal(other.DynamicType) +} + +func (t DynamicTypeWithSemanticEquals) String() string { + return fmt.Sprintf("DynamicTypeWithSemanticEquals(%t)", t.SemanticEquals) +} + +func (t DynamicTypeWithSemanticEquals) ValueFromDynamic(ctx context.Context, in basetypes.DynamicValue) (basetypes.DynamicValuable, diag.Diagnostics) { + var diags diag.Diagnostics + + value := DynamicValueWithSemanticEquals{ + DynamicValue: in, + SemanticEquals: t.SemanticEquals, + SemanticEqualsDiagnostics: t.SemanticEqualsDiagnostics, + } + + return value, diags +} + +func (t DynamicTypeWithSemanticEquals) ValueFromTerraform(ctx context.Context, in tftypes.Value) (attr.Value, error) { + attrValue, err := t.DynamicType.ValueFromTerraform(ctx, in) + + if err != nil { + return nil, err + } + + dynamicValue, ok := attrValue.(basetypes.DynamicValue) + + if !ok { + return nil, fmt.Errorf("unexpected value type of %T", attrValue) + } + + dynamicValuable, diags := t.ValueFromDynamic(ctx, dynamicValue) + + if diags.HasError() { + return nil, fmt.Errorf("unexpected error converting DynamicValue to DynamicValuable: %v", diags) + } + + return dynamicValuable, nil +} + +func (t DynamicTypeWithSemanticEquals) ValueType(ctx context.Context) attr.Value { + return DynamicValueWithSemanticEquals{ + SemanticEquals: t.SemanticEquals, + SemanticEqualsDiagnostics: t.SemanticEqualsDiagnostics, + } +} + +type DynamicValueWithSemanticEquals struct { + basetypes.DynamicValue + + SemanticEquals bool + SemanticEqualsDiagnostics diag.Diagnostics +} + +func (v DynamicValueWithSemanticEquals) Equal(o attr.Value) bool { + other, ok := o.(DynamicValueWithSemanticEquals) + + if !ok { + return false + } + + return v.DynamicValue.Equal(other.DynamicValue) +} + +func (v DynamicValueWithSemanticEquals) DynamicSemanticEquals(ctx context.Context, otherV basetypes.DynamicValuable) (bool, diag.Diagnostics) { + return v.SemanticEquals, v.SemanticEqualsDiagnostics +} + +func (v DynamicValueWithSemanticEquals) Type(ctx context.Context) attr.Type { + return DynamicTypeWithSemanticEquals{ + SemanticEquals: v.SemanticEquals, + SemanticEqualsDiagnostics: v.SemanticEqualsDiagnostics, + } +} diff --git a/internal/testing/testvalidator/dynamic.go b/internal/testing/testvalidator/dynamic.go new file mode 100644 index 000000000..6be786569 --- /dev/null +++ b/internal/testing/testvalidator/dynamic.go @@ -0,0 +1,47 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package testvalidator + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +var _ validator.Dynamic = &Dynamic{} + +// Declarative validator.Dynamic for unit testing. +type Dynamic struct { + // Dynamic interface methods + DescriptionMethod func(context.Context) string + MarkdownDescriptionMethod func(context.Context) string + ValidateDynamicMethod func(context.Context, validator.DynamicRequest, *validator.DynamicResponse) +} + +// Description satisfies the validator.Dynamic interface. +func (v Dynamic) Description(ctx context.Context) string { + if v.DescriptionMethod == nil { + return "" + } + + return v.DescriptionMethod(ctx) +} + +// MarkdownDescription satisfies the validator.Dynamic interface. +func (v Dynamic) MarkdownDescription(ctx context.Context) string { + if v.MarkdownDescriptionMethod == nil { + return "" + } + + return v.MarkdownDescriptionMethod(ctx) +} + +// Validate satisfies the validator.Dynamic interface. +func (v Dynamic) ValidateDynamic(ctx context.Context, req validator.DynamicRequest, resp *validator.DynamicResponse) { + if v.ValidateDynamicMethod == nil { + return + } + + v.ValidateDynamicMethod(ctx, req, resp) +} diff --git a/internal/toproto5/function_test.go b/internal/toproto5/function_test.go index add947b0b..758746e14 100644 --- a/internal/toproto5/function_test.go +++ b/internal/toproto5/function_test.go @@ -435,6 +435,15 @@ func TestFunctionParameter(t *testing.T) { Type: tftypes.String, }, }, + "type-dynamic": { + fw: function.DynamicParameter{ + Name: "dynamic", + }, + expected: &tfprotov5.FunctionParameter{ + Name: "dynamic", + Type: tftypes.DynamicPseudoType, + }, + }, } for name, testCase := range testCases { diff --git a/internal/toproto5/getproviderschema_test.go b/internal/toproto5/getproviderschema_test.go index ee54a5075..c89fb31cd 100644 --- a/internal/toproto5/getproviderschema_test.go +++ b/internal/toproto5/getproviderschema_test.go @@ -23,8 +23,6 @@ import ( "github.com/hashicorp/terraform-plugin-framework/types" ) -// TODO: DynamicPseudoType support -// Reference: https://github.com/hashicorp/terraform-plugin-framework/issues/147 // TODO: Tuple type support // Reference: https://github.com/hashicorp/terraform-plugin-framework/issues/54 func TestGetProviderSchemaResponse(t *testing.T) { @@ -848,6 +846,36 @@ func TestGetProviderSchemaResponse(t *testing.T) { ResourceSchemas: map[string]*tfprotov5.Schema{}, }, }, + "data-source-attribute-type-dynamic": { + input: &fwserver.GetProviderSchemaResponse{ + DataSourceSchemas: map[string]fwschema.Schema{ + "test_data_source": datasourceschema.Schema{ + Attributes: map[string]datasourceschema.Attribute{ + "test_attribute": datasourceschema.DynamicAttribute{ + Required: true, + }, + }, + }, + }, + }, + expected: &tfprotov5.GetProviderSchemaResponse{ + DataSourceSchemas: map[string]*tfprotov5.Schema{ + "test_data_source": { + Block: &tfprotov5.SchemaBlock{ + Attributes: []*tfprotov5.SchemaAttribute{ + { + Name: "test_attribute", + Required: true, + Type: tftypes.DynamicPseudoType, + }, + }, + }, + }, + }, + Functions: map[string]*tfprotov5.Function{}, + ResourceSchemas: map[string]*tfprotov5.Schema{}, + }, + }, "data-source-block-list": { input: &fwserver.GetProviderSchemaResponse{ DataSourceSchemas: map[string]fwschema.Schema{ @@ -1792,6 +1820,33 @@ func TestGetProviderSchemaResponse(t *testing.T) { ResourceSchemas: map[string]*tfprotov5.Schema{}, }, }, + "provider-attribute-type-dynamic": { + input: &fwserver.GetProviderSchemaResponse{ + Provider: providerschema.Schema{ + Attributes: map[string]providerschema.Attribute{ + "test_attribute": providerschema.DynamicAttribute{ + Required: true, + }, + }, + }, + }, + expected: &tfprotov5.GetProviderSchemaResponse{ + DataSourceSchemas: map[string]*tfprotov5.Schema{}, + Functions: map[string]*tfprotov5.Function{}, + Provider: &tfprotov5.Schema{ + Block: &tfprotov5.SchemaBlock{ + Attributes: []*tfprotov5.SchemaAttribute{ + { + Name: "test_attribute", + Required: true, + Type: tftypes.DynamicPseudoType, + }, + }, + }, + }, + ResourceSchemas: map[string]*tfprotov5.Schema{}, + }, + }, "provider-block-list": { input: &fwserver.GetProviderSchemaResponse{ Provider: providerschema.Schema{ @@ -3298,6 +3353,36 @@ func TestGetProviderSchemaResponse(t *testing.T) { }, }, }, + "resource-attribute-type-dynamic": { + input: &fwserver.GetProviderSchemaResponse{ + ResourceSchemas: map[string]fwschema.Schema{ + "test_resource": resourceschema.Schema{ + Attributes: map[string]resourceschema.Attribute{ + "test_attribute": resourceschema.DynamicAttribute{ + Required: true, + }, + }, + }, + }, + }, + expected: &tfprotov5.GetProviderSchemaResponse{ + DataSourceSchemas: map[string]*tfprotov5.Schema{}, + Functions: map[string]*tfprotov5.Function{}, + ResourceSchemas: map[string]*tfprotov5.Schema{ + "test_resource": { + Block: &tfprotov5.SchemaBlock{ + Attributes: []*tfprotov5.SchemaAttribute{ + { + Name: "test_attribute", + Required: true, + Type: tftypes.DynamicPseudoType, + }, + }, + }, + }, + }, + }, + }, "resource-block-list": { input: &fwserver.GetProviderSchemaResponse{ ResourceSchemas: map[string]fwschema.Schema{ diff --git a/internal/toproto6/function_test.go b/internal/toproto6/function_test.go index e19f7c3b3..47af4e72a 100644 --- a/internal/toproto6/function_test.go +++ b/internal/toproto6/function_test.go @@ -435,6 +435,15 @@ func TestFunctionParameter(t *testing.T) { Type: tftypes.String, }, }, + "type-dynamic": { + fw: function.DynamicParameter{ + Name: "dynamic", + }, + expected: &tfprotov6.FunctionParameter{ + Name: "dynamic", + Type: tftypes.DynamicPseudoType, + }, + }, } for name, testCase := range testCases { diff --git a/internal/toproto6/getproviderschema_test.go b/internal/toproto6/getproviderschema_test.go index f49223c2d..4ea37606c 100644 --- a/internal/toproto6/getproviderschema_test.go +++ b/internal/toproto6/getproviderschema_test.go @@ -23,8 +23,6 @@ import ( "github.com/hashicorp/terraform-plugin-framework/types" ) -// TODO: DynamicPseudoType support -// Reference: https://github.com/hashicorp/terraform-plugin-framework/issues/147 // TODO: Tuple type support // Reference: https://github.com/hashicorp/terraform-plugin-framework/issues/54 func TestGetProviderSchemaResponse(t *testing.T) { @@ -896,6 +894,36 @@ func TestGetProviderSchemaResponse(t *testing.T) { ResourceSchemas: map[string]*tfprotov6.Schema{}, }, }, + "data-source-attribute-type-dynamic": { + input: &fwserver.GetProviderSchemaResponse{ + DataSourceSchemas: map[string]fwschema.Schema{ + "test_data_source": datasourceschema.Schema{ + Attributes: map[string]datasourceschema.Attribute{ + "test_attribute": datasourceschema.DynamicAttribute{ + Required: true, + }, + }, + }, + }, + }, + expected: &tfprotov6.GetProviderSchemaResponse{ + DataSourceSchemas: map[string]*tfprotov6.Schema{ + "test_data_source": { + Block: &tfprotov6.SchemaBlock{ + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "test_attribute", + Required: true, + Type: tftypes.DynamicPseudoType, + }, + }, + }, + }, + }, + Functions: map[string]*tfprotov6.Function{}, + ResourceSchemas: map[string]*tfprotov6.Schema{}, + }, + }, "data-source-block-list": { input: &fwserver.GetProviderSchemaResponse{ DataSourceSchemas: map[string]fwschema.Schema{ @@ -1892,6 +1920,33 @@ func TestGetProviderSchemaResponse(t *testing.T) { ResourceSchemas: map[string]*tfprotov6.Schema{}, }, }, + "provider-attribute-type-dynamic": { + input: &fwserver.GetProviderSchemaResponse{ + Provider: providerschema.Schema{ + Attributes: map[string]providerschema.Attribute{ + "test_attribute": providerschema.DynamicAttribute{ + Required: true, + }, + }, + }, + }, + expected: &tfprotov6.GetProviderSchemaResponse{ + DataSourceSchemas: map[string]*tfprotov6.Schema{}, + Functions: map[string]*tfprotov6.Function{}, + Provider: &tfprotov6.Schema{ + Block: &tfprotov6.SchemaBlock{ + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "test_attribute", + Required: true, + Type: tftypes.DynamicPseudoType, + }, + }, + }, + }, + ResourceSchemas: map[string]*tfprotov6.Schema{}, + }, + }, "provider-block-list": { input: &fwserver.GetProviderSchemaResponse{ Provider: providerschema.Schema{ @@ -3498,6 +3553,36 @@ func TestGetProviderSchemaResponse(t *testing.T) { }, }, }, + "resource-attribute-type-dynamic": { + input: &fwserver.GetProviderSchemaResponse{ + ResourceSchemas: map[string]fwschema.Schema{ + "test_resource": resourceschema.Schema{ + Attributes: map[string]resourceschema.Attribute{ + "test_attribute": resourceschema.DynamicAttribute{ + Required: true, + }, + }, + }, + }, + }, + expected: &tfprotov6.GetProviderSchemaResponse{ + DataSourceSchemas: map[string]*tfprotov6.Schema{}, + Functions: map[string]*tfprotov6.Function{}, + ResourceSchemas: map[string]*tfprotov6.Schema{ + "test_resource": { + Block: &tfprotov6.SchemaBlock{ + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "test_attribute", + Required: true, + Type: tftypes.DynamicPseudoType, + }, + }, + }, + }, + }, + }, + }, "resource-block-list": { input: &fwserver.GetProviderSchemaResponse{ ResourceSchemas: map[string]fwschema.Schema{ diff --git a/provider/schema/dynamic_attribute.go b/provider/schema/dynamic_attribute.go new file mode 100644 index 000000000..c738d348d --- /dev/null +++ b/provider/schema/dynamic_attribute.go @@ -0,0 +1,177 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package schema + +import ( + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschema/fwxschema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +// Ensure the implementation satisifies the desired interfaces. +var ( + _ Attribute = DynamicAttribute{} + _ fwxschema.AttributeWithDynamicValidators = DynamicAttribute{} +) + +// DynamicAttribute represents a schema attribute that is a dynamic, rather +// than a single static type. Static types are always preferable over dynamic +// types in Terraform as practitioners will receive less helpful configuration +// assistance from validation error diagnostics and editor integrations. When +// retrieving the value for this attribute, use types.Dynamic as the value type +// unless the CustomType field is set. +// +// The concrete value type for a dynamic is determined at runtime by Terraform, +// if defined in the configuration. +type DynamicAttribute struct { + // CustomType enables the use of a custom attribute type in place of the + // default basetypes.DynamicType. When retrieving data, the basetypes.DynamicValuable + // associated with this custom type must be used in place of types.Dynamic. + CustomType basetypes.DynamicTypable + + // Required indicates whether the practitioner must enter a value for + // this attribute or not. Required and Optional cannot both be true, + // and Required and Computed cannot both be true. + Required bool + + // Optional indicates whether the practitioner can choose to enter a value + // for this attribute or not. Optional and Required cannot both be true. + Optional bool + + // Sensitive indicates whether the value of this attribute should be + // considered sensitive data. Setting it to true will obscure the value + // in CLI output. Sensitive does not impact how values are stored, and + // practitioners are encouraged to store their state as if the entire + // file is sensitive. + Sensitive bool + + // Description is used in various tooling, like the language server, to + // give practitioners more information about what this attribute is, + // what it's for, and how it should be used. It should be written as + // plain text, with no special formatting. + Description string + + // MarkdownDescription is used in various tooling, like the + // documentation generator, to give practitioners more information + // about what this attribute is, what it's for, and how it should be + // used. It should be formatted using Markdown. + MarkdownDescription string + + // DeprecationMessage defines warning diagnostic details to display when + // practitioner configurations use this Attribute. The warning diagnostic + // summary is automatically set to "Attribute Deprecated" along with + // configuration source file and line information. + // + // Set this field to a practitioner actionable message such as: + // + // - "Configure other_attribute instead. This attribute will be removed + // in the next major version of the provider." + // - "Remove this attribute's configuration as it no longer is used and + // the attribute will be removed in the next major version of the + // provider." + // + // In Terraform 1.2.7 and later, this warning diagnostic is displayed any + // time a practitioner attempts to configure a value for this attribute and + // certain scenarios where this attribute is referenced. + // + // In Terraform 1.2.6 and earlier, this warning diagnostic is only + // displayed when the Attribute is Required or Optional, and if the + // practitioner configuration sets the value to a known or unknown value + // (which may eventually be null). It has no effect when the Attribute is + // Computed-only (read-only; not Required or Optional). + // + // Across any Terraform version, there are no warnings raised for + // practitioner configuration values set directly to null, as there is no + // way for the framework to differentiate between an unset and null + // configuration due to how Terraform sends configuration information + // across the protocol. + // + // Additional information about deprecation enhancements for read-only + // attributes can be found in: + // + // - https://github.com/hashicorp/terraform/issues/7569 + // + DeprecationMessage string + + // Validators define value validation functionality for the attribute. All + // elements of the slice of AttributeValidator are run, regardless of any + // previous error diagnostics. + // + // Many common use case validators can be found in the + // github.com/hashicorp/terraform-plugin-framework-validators Go module. + // + // If the Type field points to a custom type that implements the + // xattr.TypeWithValidate interface, the validators defined in this field + // are run in addition to the validation defined by the type. + Validators []validator.Dynamic +} + +// ApplyTerraform5AttributePathStep always returns an error as it is not +// possible to step further into a DynamicAttribute. +func (a DynamicAttribute) ApplyTerraform5AttributePathStep(step tftypes.AttributePathStep) (interface{}, error) { + return a.GetType().ApplyTerraform5AttributePathStep(step) +} + +// Equal returns true if the given Attribute is a DynamicAttribute +// and all fields are equal. +func (a DynamicAttribute) Equal(o fwschema.Attribute) bool { + if _, ok := o.(DynamicAttribute); !ok { + return false + } + + return fwschema.AttributesEqual(a, o) +} + +// GetDeprecationMessage returns the DeprecationMessage field value. +func (a DynamicAttribute) GetDeprecationMessage() string { + return a.DeprecationMessage +} + +// GetDescription returns the Description field value. +func (a DynamicAttribute) GetDescription() string { + return a.Description +} + +// GetMarkdownDescription returns the MarkdownDescription field value. +func (a DynamicAttribute) GetMarkdownDescription() string { + return a.MarkdownDescription +} + +// GetType returns types.DynamicType or the CustomType field value if defined. +func (a DynamicAttribute) GetType() attr.Type { + if a.CustomType != nil { + return a.CustomType + } + + return types.DynamicType +} + +// IsComputed always returns false as provider schemas cannot be Computed. +func (a DynamicAttribute) IsComputed() bool { + return false +} + +// IsOptional returns the Optional field value. +func (a DynamicAttribute) IsOptional() bool { + return a.Optional +} + +// IsRequired returns the Required field value. +func (a DynamicAttribute) IsRequired() bool { + return a.Required +} + +// IsSensitive returns the Sensitive field value. +func (a DynamicAttribute) IsSensitive() bool { + return a.Sensitive +} + +// DynamicValidators returns the Validators field value. +func (a DynamicAttribute) DynamicValidators() []validator.Dynamic { + return a.Validators +} diff --git a/provider/schema/dynamic_attribute_test.go b/provider/schema/dynamic_attribute_test.go new file mode 100644 index 000000000..c06eaf811 --- /dev/null +++ b/provider/schema/dynamic_attribute_test.go @@ -0,0 +1,419 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package schema_test + +import ( + "fmt" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" + "github.com/hashicorp/terraform-plugin-framework/internal/testing/testschema" + "github.com/hashicorp/terraform-plugin-framework/internal/testing/testtypes" + "github.com/hashicorp/terraform-plugin-framework/provider/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestDynamicAttributeApplyTerraform5AttributePathStep(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attribute schema.DynamicAttribute + step tftypes.AttributePathStep + expected any + expectedError error + }{ + "AttributeName": { + attribute: schema.DynamicAttribute{}, + step: tftypes.AttributeName("test"), + expected: nil, + expectedError: fmt.Errorf("cannot apply AttributePathStep tftypes.AttributeName to basetypes.DynamicType"), + }, + "ElementKeyInt": { + attribute: schema.DynamicAttribute{}, + step: tftypes.ElementKeyInt(1), + expected: nil, + expectedError: fmt.Errorf("cannot apply AttributePathStep tftypes.ElementKeyInt to basetypes.DynamicType"), + }, + "ElementKeyString": { + attribute: schema.DynamicAttribute{}, + step: tftypes.ElementKeyString("test"), + expected: nil, + expectedError: fmt.Errorf("cannot apply AttributePathStep tftypes.ElementKeyString to basetypes.DynamicType"), + }, + "ElementKeyValue": { + attribute: schema.DynamicAttribute{}, + step: tftypes.ElementKeyValue(tftypes.NewValue(tftypes.String, "test")), + expected: nil, + expectedError: fmt.Errorf("cannot apply AttributePathStep tftypes.ElementKeyValue to basetypes.DynamicType"), + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, err := testCase.attribute.ApplyTerraform5AttributePathStep(testCase.step) + + if err != nil { + if testCase.expectedError == nil { + t.Fatalf("expected no error, got: %s", err) + } + + if !strings.Contains(err.Error(), testCase.expectedError.Error()) { + t.Fatalf("expected error %q, got: %s", testCase.expectedError, err) + } + } + + if err == nil && testCase.expectedError != nil { + t.Fatalf("got no error, expected: %s", testCase.expectedError) + } + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestDynamicAttributeGetDeprecationMessage(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attribute schema.DynamicAttribute + expected string + }{ + "no-deprecation-message": { + attribute: schema.DynamicAttribute{}, + expected: "", + }, + "deprecation-message": { + attribute: schema.DynamicAttribute{ + DeprecationMessage: "test deprecation message", + }, + expected: "test deprecation message", + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.attribute.GetDeprecationMessage() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestDynamicAttributeEqual(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attribute schema.DynamicAttribute + other fwschema.Attribute + expected bool + }{ + "different-type": { + attribute: schema.DynamicAttribute{}, + other: testschema.AttributeWithDynamicValidators{}, + expected: false, + }, + "equal": { + attribute: schema.DynamicAttribute{}, + other: schema.DynamicAttribute{}, + expected: true, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.attribute.Equal(testCase.other) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestDynamicAttributeGetDescription(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attribute schema.DynamicAttribute + expected string + }{ + "no-description": { + attribute: schema.DynamicAttribute{}, + expected: "", + }, + "description": { + attribute: schema.DynamicAttribute{ + Description: "test description", + }, + expected: "test description", + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.attribute.GetDescription() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestDynamicAttributeGetMarkdownDescription(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attribute schema.DynamicAttribute + expected string + }{ + "no-markdown-description": { + attribute: schema.DynamicAttribute{}, + expected: "", + }, + "markdown-description": { + attribute: schema.DynamicAttribute{ + MarkdownDescription: "test description", + }, + expected: "test description", + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.attribute.GetMarkdownDescription() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestDynamicAttributeGetType(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attribute schema.DynamicAttribute + expected attr.Type + }{ + "base": { + attribute: schema.DynamicAttribute{}, + expected: types.DynamicType, + }, + "custom-type": { + attribute: schema.DynamicAttribute{ + CustomType: testtypes.DynamicType{}, + }, + expected: testtypes.DynamicType{}, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.attribute.GetType() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestDynamicAttributeIsComputed(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attribute schema.DynamicAttribute + expected bool + }{ + "not-computed": { + attribute: schema.DynamicAttribute{}, + expected: false, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.attribute.IsComputed() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestDynamicAttributeIsOptional(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attribute schema.DynamicAttribute + expected bool + }{ + "not-optional": { + attribute: schema.DynamicAttribute{}, + expected: false, + }, + "optional": { + attribute: schema.DynamicAttribute{ + Optional: true, + }, + expected: true, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.attribute.IsOptional() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestDynamicAttributeIsRequired(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attribute schema.DynamicAttribute + expected bool + }{ + "not-required": { + attribute: schema.DynamicAttribute{}, + expected: false, + }, + "required": { + attribute: schema.DynamicAttribute{ + Required: true, + }, + expected: true, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.attribute.IsRequired() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestDynamicAttributeIsSensitive(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attribute schema.DynamicAttribute + expected bool + }{ + "not-sensitive": { + attribute: schema.DynamicAttribute{}, + expected: false, + }, + "sensitive": { + attribute: schema.DynamicAttribute{ + Sensitive: true, + }, + expected: true, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.attribute.IsSensitive() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestDynamicAttributeDynamicValidators(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attribute schema.DynamicAttribute + expected []validator.Dynamic + }{ + "no-validators": { + attribute: schema.DynamicAttribute{}, + expected: nil, + }, + "validators": { + attribute: schema.DynamicAttribute{ + Validators: []validator.Dynamic{}, + }, + expected: []validator.Dynamic{}, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.attribute.DynamicValidators() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/provider/schema/list_attribute.go b/provider/schema/list_attribute.go index fe4cf3c18..e733b297f 100644 --- a/provider/schema/list_attribute.go +++ b/provider/schema/list_attribute.go @@ -9,6 +9,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema/fwxschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwtype" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" @@ -44,6 +45,10 @@ var ( type ListAttribute struct { // ElementType is the type for all elements of the list. This field must be // set. + // + // Element types that contain a dynamic type (i.e. types.Dynamic) are not supported. + // If underlying dynamic values are required, replace this attribute definition with + // DynamicAttribute instead. ElementType attr.Type // CustomType enables the use of a custom attribute type in place of the @@ -203,4 +208,8 @@ func (a ListAttribute) ValidateImplementation(ctx context.Context, req fwschema. if a.CustomType == nil && a.ElementType == nil { resp.Diagnostics.Append(fwschema.AttributeMissingElementTypeDiag(req.Path)) } + + if a.CustomType == nil && fwtype.ContainsCollectionWithDynamic(a.GetType()) { + resp.Diagnostics.Append(fwtype.AttributeCollectionWithDynamicTypeDiag(req.Path)) + } } diff --git a/provider/schema/list_attribute_test.go b/provider/schema/list_attribute_test.go index c9da8811d..cde4bc7c7 100644 --- a/provider/schema/list_attribute_test.go +++ b/provider/schema/list_attribute_test.go @@ -456,6 +456,28 @@ func TestListAttributeValidateImplementation(t *testing.T) { }, expected: &fwschema.ValidateImplementationResponse{}, }, + "elementtype-dynamic": { + attribute: schema.ListAttribute{ + Optional: true, + ElementType: types.DynamicType, + }, + request: fwschema.ValidateImplementationRequest{ + Name: "test", + Path: path.Root("test"), + }, + expected: &fwschema.ValidateImplementationResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Schema Implementation", + "When validating the schema, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "\"test\" is an attribute that contains a collection type with a nested dynamic type.\n\n"+ + "Dynamic types inside of collections are not currently supported in terraform-plugin-framework. "+ + "If underlying dynamic values are required, replace the \"test\" attribute definition with DynamicAttribute instead.", + ), + }, + }, + }, "elementtype-missing": { attribute: schema.ListAttribute{ Optional: true, diff --git a/provider/schema/list_nested_attribute.go b/provider/schema/list_nested_attribute.go index 965f991d9..0c82da4ad 100644 --- a/provider/schema/list_nested_attribute.go +++ b/provider/schema/list_nested_attribute.go @@ -4,11 +4,13 @@ package schema import ( + "context" "fmt" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema/fwxschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwtype" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" @@ -17,8 +19,9 @@ import ( // Ensure the implementation satisifies the desired interfaces. var ( - _ NestedAttribute = ListNestedAttribute{} - _ fwxschema.AttributeWithListValidators = ListNestedAttribute{} + _ NestedAttribute = ListNestedAttribute{} + _ fwschema.AttributeWithValidateImplementation = ListNestedAttribute{} + _ fwxschema.AttributeWithListValidators = ListNestedAttribute{} ) // ListNestedAttribute represents an attribute that is a list of objects where @@ -51,6 +54,10 @@ var ( type ListNestedAttribute struct { // NestedObject is the underlying object that contains nested attributes. // This field must be set. + // + // Nested attributes that contain a dynamic type (i.e. DynamicAttribute) are not supported. + // If underlying dynamic values are required, replace this attribute definition with + // DynamicAttribute instead. NestedObject NestedAttributeObject // CustomType enables the use of a custom attribute type in place of the @@ -220,3 +227,13 @@ func (a ListNestedAttribute) IsSensitive() bool { func (a ListNestedAttribute) ListValidators() []validator.List { return a.Validators } + +// ValidateImplementation contains logic for validating the +// provider-defined implementation of the attribute to prevent unexpected +// errors or panics. This logic runs during the GetProviderSchema RPC and +// should never include false positives. +func (a ListNestedAttribute) ValidateImplementation(ctx context.Context, req fwschema.ValidateImplementationRequest, resp *fwschema.ValidateImplementationResponse) { + if a.CustomType == nil && fwtype.ContainsCollectionWithDynamic(a.GetType()) { + resp.Diagnostics.Append(fwtype.AttributeCollectionWithDynamicTypeDiag(req.Path)) + } +} diff --git a/provider/schema/list_nested_attribute_test.go b/provider/schema/list_nested_attribute_test.go index fe8ac036d..93d23a555 100644 --- a/provider/schema/list_nested_attribute_test.go +++ b/provider/schema/list_nested_attribute_test.go @@ -4,14 +4,18 @@ package schema_test import ( + "context" "fmt" "strings" "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/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/testing/testschema" + "github.com/hashicorp/terraform-plugin-framework/internal/testing/testtypes" + "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/provider/schema" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" @@ -598,3 +602,83 @@ func TestListNestedAttributeListValidators(t *testing.T) { }) } } + +func TestListNestedAttributeValidateImplementation(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attribute schema.ListNestedAttribute + request fwschema.ValidateImplementationRequest + expected *fwschema.ValidateImplementationResponse + }{ + "customtype": { + attribute: schema.ListNestedAttribute{ + Optional: true, + CustomType: testtypes.ListType{}, + }, + request: fwschema.ValidateImplementationRequest{ + Name: "test", + Path: path.Root("test"), + }, + expected: &fwschema.ValidateImplementationResponse{}, + }, + "nestedobject": { + attribute: schema.ListNestedAttribute{ + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "test_attr": schema.StringAttribute{ + Optional: true, + }, + }, + }, + }, + request: fwschema.ValidateImplementationRequest{ + Name: "test", + Path: path.Root("test"), + }, + expected: &fwschema.ValidateImplementationResponse{}, + }, + "nestedobject-dynamic": { + attribute: schema.ListNestedAttribute{ + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "test_dyn": schema.DynamicAttribute{ + Optional: true, + }, + }, + }, + }, + request: fwschema.ValidateImplementationRequest{ + Name: "test", + Path: path.Root("test"), + }, + expected: &fwschema.ValidateImplementationResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Schema Implementation", + "When validating the schema, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "\"test\" is an attribute that contains a collection type with a nested dynamic type.\n\n"+ + "Dynamic types inside of collections are not currently supported in terraform-plugin-framework. "+ + "If underlying dynamic values are required, replace the \"test\" attribute definition with DynamicAttribute instead.", + ), + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := &fwschema.ValidateImplementationResponse{} + testCase.attribute.ValidateImplementation(context.Background(), testCase.request, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/provider/schema/list_nested_block.go b/provider/schema/list_nested_block.go index 0722d54d4..4d098bc2d 100644 --- a/provider/schema/list_nested_block.go +++ b/provider/schema/list_nested_block.go @@ -4,11 +4,13 @@ package schema import ( + "context" "fmt" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema/fwxschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwtype" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" @@ -17,8 +19,9 @@ import ( // Ensure the implementation satisifies the desired interfaces. var ( - _ Block = ListNestedBlock{} - _ fwxschema.BlockWithListValidators = ListNestedBlock{} + _ Block = ListNestedBlock{} + _ fwschema.BlockWithValidateImplementation = ListNestedBlock{} + _ fwxschema.BlockWithListValidators = ListNestedBlock{} ) // ListNestedBlock represents a block that is a list of objects where @@ -55,6 +58,10 @@ var ( type ListNestedBlock struct { // NestedObject is the underlying object that contains nested attributes or // blocks. This field must be set. + // + // Nested attributes that contain a dynamic type (i.e. DynamicAttribute) are not supported. + // If underlying dynamic values are required, replace this block definition with + // a DynamicAttribute. NestedObject NestedBlockObject // CustomType enables the use of a custom attribute type in place of the @@ -186,3 +193,13 @@ func (b ListNestedBlock) Type() attr.Type { ElemType: b.NestedObject.Type(), } } + +// ValidateImplementation contains logic for validating the +// provider-defined implementation of the block to prevent unexpected +// errors or panics. This logic runs during the GetProviderSchema RPC and +// should never include false positives. +func (b ListNestedBlock) ValidateImplementation(ctx context.Context, req fwschema.ValidateImplementationRequest, resp *fwschema.ValidateImplementationResponse) { + if b.CustomType == nil && fwtype.ContainsCollectionWithDynamic(b.Type()) { + resp.Diagnostics.Append(fwtype.BlockCollectionWithDynamicTypeDiag(req.Path)) + } +} diff --git a/provider/schema/list_nested_block_test.go b/provider/schema/list_nested_block_test.go index f1f3ad6f3..d8ad26e00 100644 --- a/provider/schema/list_nested_block_test.go +++ b/provider/schema/list_nested_block_test.go @@ -4,14 +4,18 @@ package schema_test import ( + "context" "fmt" "strings" "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/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/testing/testschema" + "github.com/hashicorp/terraform-plugin-framework/internal/testing/testtypes" + "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/provider/schema" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" @@ -485,3 +489,82 @@ func TestListNestedBlockType(t *testing.T) { }) } } + +func TestListNestedBlockValidateImplementation(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + block schema.ListNestedBlock + request fwschema.ValidateImplementationRequest + expected *fwschema.ValidateImplementationResponse + }{ + "customtype": { + block: schema.ListNestedBlock{ + CustomType: testtypes.ListType{}, + }, + request: fwschema.ValidateImplementationRequest{ + Name: "test", + Path: path.Root("test"), + }, + expected: &fwschema.ValidateImplementationResponse{}, + }, + "nestedobject": { + block: schema.ListNestedBlock{ + NestedObject: schema.NestedBlockObject{ + Attributes: map[string]schema.Attribute{ + "test_attr": schema.StringAttribute{ + Optional: true, + }, + }, + }, + }, + request: fwschema.ValidateImplementationRequest{ + Name: "test", + Path: path.Root("test"), + }, + expected: &fwschema.ValidateImplementationResponse{}, + }, + "nestedobject-dynamic": { + block: schema.ListNestedBlock{ + NestedObject: schema.NestedBlockObject{ + Attributes: map[string]schema.Attribute{ + "test_dyn": schema.DynamicAttribute{ + Optional: true, + }, + }, + }, + }, + request: fwschema.ValidateImplementationRequest{ + Name: "test", + Path: path.Root("test"), + }, + expected: &fwschema.ValidateImplementationResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Schema Implementation", + "When validating the schema, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "\"test\" is a block that contains a collection type with a nested dynamic type.\n\n"+ + "Dynamic types inside of collections are not currently supported in terraform-plugin-framework. "+ + "If underlying dynamic values are required, replace the \"test\" block definition with a DynamicAttribute.", + ), + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := &fwschema.ValidateImplementationResponse{} + testCase.block.ValidateImplementation(context.Background(), testCase.request, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/provider/schema/map_attribute.go b/provider/schema/map_attribute.go index 4a3d800a6..079535e77 100644 --- a/provider/schema/map_attribute.go +++ b/provider/schema/map_attribute.go @@ -9,6 +9,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema/fwxschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwtype" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" @@ -47,6 +48,10 @@ var ( type MapAttribute struct { // ElementType is the type for all elements of the map. This field must be // set. + // + // Element types that contain a dynamic type (i.e. types.Dynamic) are not supported. + // If underlying dynamic values are required, replace this attribute definition with + // DynamicAttribute instead. ElementType attr.Type // CustomType enables the use of a custom attribute type in place of the @@ -206,4 +211,8 @@ func (a MapAttribute) ValidateImplementation(ctx context.Context, req fwschema.V if a.CustomType == nil && a.ElementType == nil { resp.Diagnostics.Append(fwschema.AttributeMissingElementTypeDiag(req.Path)) } + + if a.CustomType == nil && fwtype.ContainsCollectionWithDynamic(a.GetType()) { + resp.Diagnostics.Append(fwtype.AttributeCollectionWithDynamicTypeDiag(req.Path)) + } } diff --git a/provider/schema/map_attribute_test.go b/provider/schema/map_attribute_test.go index b1a3dc760..d16e728c2 100644 --- a/provider/schema/map_attribute_test.go +++ b/provider/schema/map_attribute_test.go @@ -456,6 +456,28 @@ func TestMapAttributeValidateImplementation(t *testing.T) { }, expected: &fwschema.ValidateImplementationResponse{}, }, + "elementtype-dynamic": { + attribute: schema.MapAttribute{ + Optional: true, + ElementType: types.DynamicType, + }, + request: fwschema.ValidateImplementationRequest{ + Name: "test", + Path: path.Root("test"), + }, + expected: &fwschema.ValidateImplementationResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Schema Implementation", + "When validating the schema, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "\"test\" is an attribute that contains a collection type with a nested dynamic type.\n\n"+ + "Dynamic types inside of collections are not currently supported in terraform-plugin-framework. "+ + "If underlying dynamic values are required, replace the \"test\" attribute definition with DynamicAttribute instead.", + ), + }, + }, + }, "elementtype-missing": { attribute: schema.MapAttribute{ Optional: true, diff --git a/provider/schema/map_nested_attribute.go b/provider/schema/map_nested_attribute.go index 44da39c63..0cdc9b5e0 100644 --- a/provider/schema/map_nested_attribute.go +++ b/provider/schema/map_nested_attribute.go @@ -4,6 +4,7 @@ package schema import ( + "context" "fmt" "github.com/hashicorp/terraform-plugin-go/tftypes" @@ -11,6 +12,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema/fwxschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwtype" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" @@ -52,6 +54,10 @@ var ( type MapNestedAttribute struct { // NestedObject is the underlying object that contains nested attributes. // This field must be set. + // + // Nested attributes that contain a dynamic type (i.e. DynamicAttribute) are not supported. + // If underlying dynamic values are required, replace this attribute definition with + // DynamicAttribute instead. NestedObject NestedAttributeObject // CustomType enables the use of a custom attribute type in place of the @@ -221,3 +227,13 @@ func (a MapNestedAttribute) IsSensitive() bool { func (a MapNestedAttribute) MapValidators() []validator.Map { return a.Validators } + +// ValidateImplementation contains logic for validating the +// provider-defined implementation of the attribute to prevent unexpected +// errors or panics. This logic runs during the GetProviderSchema RPC and +// should never include false positives. +func (a MapNestedAttribute) ValidateImplementation(ctx context.Context, req fwschema.ValidateImplementationRequest, resp *fwschema.ValidateImplementationResponse) { + if a.CustomType == nil && fwtype.ContainsCollectionWithDynamic(a.GetType()) { + resp.Diagnostics.Append(fwtype.AttributeCollectionWithDynamicTypeDiag(req.Path)) + } +} diff --git a/provider/schema/map_nested_attribute_test.go b/provider/schema/map_nested_attribute_test.go index 1400b1678..ec0598bf0 100644 --- a/provider/schema/map_nested_attribute_test.go +++ b/provider/schema/map_nested_attribute_test.go @@ -4,14 +4,18 @@ package schema_test import ( + "context" "fmt" "strings" "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/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/testing/testschema" + "github.com/hashicorp/terraform-plugin-framework/internal/testing/testtypes" + "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/provider/schema" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" @@ -598,3 +602,83 @@ func TestMapNestedAttributeMapNestedValidators(t *testing.T) { }) } } + +func TestMapNestedAttributeValidateImplementation(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attribute schema.MapNestedAttribute + request fwschema.ValidateImplementationRequest + expected *fwschema.ValidateImplementationResponse + }{ + "customtype": { + attribute: schema.MapNestedAttribute{ + Optional: true, + CustomType: testtypes.MapType{}, + }, + request: fwschema.ValidateImplementationRequest{ + Name: "test", + Path: path.Root("test"), + }, + expected: &fwschema.ValidateImplementationResponse{}, + }, + "nestedobject": { + attribute: schema.MapNestedAttribute{ + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "test_attr": schema.StringAttribute{ + Optional: true, + }, + }, + }, + }, + request: fwschema.ValidateImplementationRequest{ + Name: "test", + Path: path.Root("test"), + }, + expected: &fwschema.ValidateImplementationResponse{}, + }, + "nestedobject-dynamic": { + attribute: schema.MapNestedAttribute{ + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "test_dyn": schema.DynamicAttribute{ + Optional: true, + }, + }, + }, + }, + request: fwschema.ValidateImplementationRequest{ + Name: "test", + Path: path.Root("test"), + }, + expected: &fwschema.ValidateImplementationResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Schema Implementation", + "When validating the schema, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "\"test\" is an attribute that contains a collection type with a nested dynamic type.\n\n"+ + "Dynamic types inside of collections are not currently supported in terraform-plugin-framework. "+ + "If underlying dynamic values are required, replace the \"test\" attribute definition with DynamicAttribute instead.", + ), + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := &fwschema.ValidateImplementationResponse{} + testCase.attribute.ValidateImplementation(context.Background(), testCase.request, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/provider/schema/object_attribute.go b/provider/schema/object_attribute.go index c69445656..3041f5a79 100644 --- a/provider/schema/object_attribute.go +++ b/provider/schema/object_attribute.go @@ -9,6 +9,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema/fwxschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwtype" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" @@ -46,6 +47,10 @@ var ( type ObjectAttribute struct { // AttributeTypes is the mapping of underlying attribute names to attribute // types. This field must be set. + // + // Attribute types that contain a collection with a nested dynamic type (i.e. types.List[types.Dynamic]) are not supported. + // If underlying dynamic collection values are required, replace this attribute definition with + // DynamicAttribute instead. AttributeTypes map[string]attr.Type // CustomType enables the use of a custom attribute type in place of the @@ -205,4 +210,8 @@ func (a ObjectAttribute) ValidateImplementation(ctx context.Context, req fwschem if a.AttributeTypes == nil && a.CustomType == nil { resp.Diagnostics.Append(fwschema.AttributeMissingAttributeTypesDiag(req.Path)) } + + if a.CustomType == nil && fwtype.ContainsCollectionWithDynamic(a.GetType()) { + resp.Diagnostics.Append(fwtype.AttributeCollectionWithDynamicTypeDiag(req.Path)) + } } diff --git a/provider/schema/object_attribute_test.go b/provider/schema/object_attribute_test.go index 0dc8c0464..4fd9c9d9a 100644 --- a/provider/schema/object_attribute_test.go +++ b/provider/schema/object_attribute_test.go @@ -453,6 +453,53 @@ func TestObjectAttributeValidateImplementation(t *testing.T) { }, expected: &fwschema.ValidateImplementationResponse{}, }, + "attributetypes-dynamic": { + attribute: schema.ObjectAttribute{ + AttributeTypes: map[string]attr.Type{ + "test_attr": types.DynamicType, + "test_list": types.ListType{ + ElemType: types.StringType, + }, + "test_obj": types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "test_attr": types.DynamicType, + }, + }, + }, + Optional: true, + }, + request: fwschema.ValidateImplementationRequest{ + Name: "test", + Path: path.Root("test"), + }, + expected: &fwschema.ValidateImplementationResponse{}, + }, + "attributetypes-nested-collection-dynamic": { + attribute: schema.ObjectAttribute{ + AttributeTypes: map[string]attr.Type{ + "test_attr": types.ListType{ + ElemType: types.DynamicType, + }, + }, + Optional: true, + }, + request: fwschema.ValidateImplementationRequest{ + Name: "test", + Path: path.Root("test"), + }, + expected: &fwschema.ValidateImplementationResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Schema Implementation", + "When validating the schema, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "\"test\" is an attribute that contains a collection type with a nested dynamic type.\n\n"+ + "Dynamic types inside of collections are not currently supported in terraform-plugin-framework. "+ + "If underlying dynamic values are required, replace the \"test\" attribute definition with DynamicAttribute instead.", + ), + }, + }, + }, "attributetypes-missing": { attribute: schema.ObjectAttribute{ Optional: true, diff --git a/provider/schema/set_attribute.go b/provider/schema/set_attribute.go index 9d8cfe9d4..3297452b7 100644 --- a/provider/schema/set_attribute.go +++ b/provider/schema/set_attribute.go @@ -9,6 +9,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema/fwxschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwtype" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" @@ -42,6 +43,10 @@ var ( type SetAttribute struct { // ElementType is the type for all elements of the set. This field must be // set. + // + // Element types that contain a dynamic type (i.e. types.Dynamic) are not supported. + // If underlying dynamic values are required, replace this attribute definition with + // DynamicAttribute instead. ElementType attr.Type // CustomType enables the use of a custom attribute type in place of the @@ -201,4 +206,8 @@ func (a SetAttribute) ValidateImplementation(ctx context.Context, req fwschema.V if a.CustomType == nil && a.ElementType == nil { resp.Diagnostics.Append(fwschema.AttributeMissingElementTypeDiag(req.Path)) } + + if a.CustomType == nil && fwtype.ContainsCollectionWithDynamic(a.GetType()) { + resp.Diagnostics.Append(fwtype.AttributeCollectionWithDynamicTypeDiag(req.Path)) + } } diff --git a/provider/schema/set_attribute_test.go b/provider/schema/set_attribute_test.go index 74daeb23c..b62366ed9 100644 --- a/provider/schema/set_attribute_test.go +++ b/provider/schema/set_attribute_test.go @@ -456,6 +456,28 @@ func TestSetAttributeValidateImplementation(t *testing.T) { }, expected: &fwschema.ValidateImplementationResponse{}, }, + "elementtype-dynamic": { + attribute: schema.SetAttribute{ + Optional: true, + ElementType: types.DynamicType, + }, + request: fwschema.ValidateImplementationRequest{ + Name: "test", + Path: path.Root("test"), + }, + expected: &fwschema.ValidateImplementationResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Schema Implementation", + "When validating the schema, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "\"test\" is an attribute that contains a collection type with a nested dynamic type.\n\n"+ + "Dynamic types inside of collections are not currently supported in terraform-plugin-framework. "+ + "If underlying dynamic values are required, replace the \"test\" attribute definition with DynamicAttribute instead.", + ), + }, + }, + }, "elementtype-missing": { attribute: schema.SetAttribute{ Optional: true, diff --git a/provider/schema/set_nested_attribute.go b/provider/schema/set_nested_attribute.go index 1d02f3ce3..64fed1ea2 100644 --- a/provider/schema/set_nested_attribute.go +++ b/provider/schema/set_nested_attribute.go @@ -4,6 +4,7 @@ package schema import ( + "context" "fmt" "github.com/hashicorp/terraform-plugin-go/tftypes" @@ -11,6 +12,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema/fwxschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwtype" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" @@ -18,8 +20,9 @@ import ( // Ensure the implementation satisifies the desired interfaces. var ( - _ NestedAttribute = SetNestedAttribute{} - _ fwxschema.AttributeWithSetValidators = SetNestedAttribute{} + _ NestedAttribute = SetNestedAttribute{} + _ fwschema.AttributeWithValidateImplementation = SetNestedAttribute{} + _ fwxschema.AttributeWithSetValidators = SetNestedAttribute{} ) // SetNestedAttribute represents an attribute that is a set of objects where @@ -47,6 +50,10 @@ var ( type SetNestedAttribute struct { // NestedObject is the underlying object that contains nested attributes. // This field must be set. + // + // Nested attributes that contain a dynamic type (i.e. DynamicAttribute) are not supported. + // If underlying dynamic values are required, replace this attribute definition with + // DynamicAttribute instead. NestedObject NestedAttributeObject // CustomType enables the use of a custom attribute type in place of the @@ -216,3 +223,13 @@ func (a SetNestedAttribute) IsSensitive() bool { func (a SetNestedAttribute) SetValidators() []validator.Set { return a.Validators } + +// ValidateImplementation contains logic for validating the +// provider-defined implementation of the attribute to prevent unexpected +// errors or panics. This logic runs during the GetProviderSchema RPC and +// should never include false positives. +func (a SetNestedAttribute) ValidateImplementation(ctx context.Context, req fwschema.ValidateImplementationRequest, resp *fwschema.ValidateImplementationResponse) { + if a.CustomType == nil && fwtype.ContainsCollectionWithDynamic(a.GetType()) { + resp.Diagnostics.Append(fwtype.AttributeCollectionWithDynamicTypeDiag(req.Path)) + } +} diff --git a/provider/schema/set_nested_attribute_test.go b/provider/schema/set_nested_attribute_test.go index 4d52a0cdf..32c6b810b 100644 --- a/provider/schema/set_nested_attribute_test.go +++ b/provider/schema/set_nested_attribute_test.go @@ -4,14 +4,18 @@ package schema_test import ( + "context" "fmt" "strings" "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/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/testing/testschema" + "github.com/hashicorp/terraform-plugin-framework/internal/testing/testtypes" + "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/provider/schema" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" @@ -598,3 +602,83 @@ func TestSetNestedAttributeSetValidators(t *testing.T) { }) } } + +func TestSetNestedAttributeValidateImplementation(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attribute schema.SetNestedAttribute + request fwschema.ValidateImplementationRequest + expected *fwschema.ValidateImplementationResponse + }{ + "customtype": { + attribute: schema.SetNestedAttribute{ + Optional: true, + CustomType: testtypes.SetType{}, + }, + request: fwschema.ValidateImplementationRequest{ + Name: "test", + Path: path.Root("test"), + }, + expected: &fwschema.ValidateImplementationResponse{}, + }, + "nestedobject": { + attribute: schema.SetNestedAttribute{ + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "test_attr": schema.StringAttribute{ + Optional: true, + }, + }, + }, + }, + request: fwschema.ValidateImplementationRequest{ + Name: "test", + Path: path.Root("test"), + }, + expected: &fwschema.ValidateImplementationResponse{}, + }, + "nestedobject-dynamic": { + attribute: schema.SetNestedAttribute{ + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "test_dyn": schema.DynamicAttribute{ + Optional: true, + }, + }, + }, + }, + request: fwschema.ValidateImplementationRequest{ + Name: "test", + Path: path.Root("test"), + }, + expected: &fwschema.ValidateImplementationResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Schema Implementation", + "When validating the schema, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "\"test\" is an attribute that contains a collection type with a nested dynamic type.\n\n"+ + "Dynamic types inside of collections are not currently supported in terraform-plugin-framework. "+ + "If underlying dynamic values are required, replace the \"test\" attribute definition with DynamicAttribute instead.", + ), + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := &fwschema.ValidateImplementationResponse{} + testCase.attribute.ValidateImplementation(context.Background(), testCase.request, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/provider/schema/set_nested_block.go b/provider/schema/set_nested_block.go index 8e89ff944..085163f37 100644 --- a/provider/schema/set_nested_block.go +++ b/provider/schema/set_nested_block.go @@ -4,11 +4,13 @@ package schema import ( + "context" "fmt" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema/fwxschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwtype" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" @@ -17,8 +19,9 @@ import ( // Ensure the implementation satisifies the desired interfaces. var ( - _ Block = SetNestedBlock{} - _ fwxschema.BlockWithSetValidators = SetNestedBlock{} + _ Block = SetNestedBlock{} + _ fwschema.BlockWithValidateImplementation = SetNestedBlock{} + _ fwxschema.BlockWithSetValidators = SetNestedBlock{} ) // SetNestedBlock represents a block that is a set of objects where @@ -55,6 +58,10 @@ var ( type SetNestedBlock struct { // NestedObject is the underlying object that contains nested attributes or // blocks. This field must be set. + // + // Nested attributes that contain a dynamic type (i.e. DynamicAttribute) are not supported. + // If underlying dynamic values are required, replace this block definition with + // a DynamicAttribute. NestedObject NestedBlockObject // CustomType enables the use of a custom attribute type in place of the @@ -186,3 +193,13 @@ func (b SetNestedBlock) Type() attr.Type { ElemType: b.NestedObject.Type(), } } + +// ValidateImplementation contains logic for validating the +// provider-defined implementation of the block to prevent unexpected +// errors or panics. This logic runs during the GetProviderSchema RPC and +// should never include false positives. +func (b SetNestedBlock) ValidateImplementation(ctx context.Context, req fwschema.ValidateImplementationRequest, resp *fwschema.ValidateImplementationResponse) { + if b.CustomType == nil && fwtype.ContainsCollectionWithDynamic(b.Type()) { + resp.Diagnostics.Append(fwtype.BlockCollectionWithDynamicTypeDiag(req.Path)) + } +} diff --git a/provider/schema/set_nested_block_test.go b/provider/schema/set_nested_block_test.go index c1d05f274..ab0481f14 100644 --- a/provider/schema/set_nested_block_test.go +++ b/provider/schema/set_nested_block_test.go @@ -4,14 +4,18 @@ package schema_test import ( + "context" "fmt" "strings" "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/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/testing/testschema" + "github.com/hashicorp/terraform-plugin-framework/internal/testing/testtypes" + "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/provider/schema" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" @@ -485,3 +489,82 @@ func TestSetNestedBlockType(t *testing.T) { }) } } + +func TestSetNestedBlockValidateImplementation(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + block schema.SetNestedBlock + request fwschema.ValidateImplementationRequest + expected *fwschema.ValidateImplementationResponse + }{ + "customtype": { + block: schema.SetNestedBlock{ + CustomType: testtypes.SetType{}, + }, + request: fwschema.ValidateImplementationRequest{ + Name: "test", + Path: path.Root("test"), + }, + expected: &fwschema.ValidateImplementationResponse{}, + }, + "nestedobject": { + block: schema.SetNestedBlock{ + NestedObject: schema.NestedBlockObject{ + Attributes: map[string]schema.Attribute{ + "test_attr": schema.StringAttribute{ + Optional: true, + }, + }, + }, + }, + request: fwschema.ValidateImplementationRequest{ + Name: "test", + Path: path.Root("test"), + }, + expected: &fwschema.ValidateImplementationResponse{}, + }, + "nestedobject-dynamic": { + block: schema.SetNestedBlock{ + NestedObject: schema.NestedBlockObject{ + Attributes: map[string]schema.Attribute{ + "test_dyn": schema.DynamicAttribute{ + Optional: true, + }, + }, + }, + }, + request: fwschema.ValidateImplementationRequest{ + Name: "test", + Path: path.Root("test"), + }, + expected: &fwschema.ValidateImplementationResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Schema Implementation", + "When validating the schema, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "\"test\" is a block that contains a collection type with a nested dynamic type.\n\n"+ + "Dynamic types inside of collections are not currently supported in terraform-plugin-framework. "+ + "If underlying dynamic values are required, replace the \"test\" block definition with a DynamicAttribute.", + ), + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := &fwschema.ValidateImplementationResponse{} + testCase.block.ValidateImplementation(context.Background(), testCase.request, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/defaults/dynamic.go b/resource/schema/defaults/dynamic.go new file mode 100644 index 000000000..313a3d699 --- /dev/null +++ b/resource/schema/defaults/dynamic.go @@ -0,0 +1,36 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package defaults + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// Dynamic is a schema default value for types.Dynamic attributes. +type Dynamic interface { + Describer + + // DefaultDynamic should set the default value. + DefaultDynamic(context.Context, DynamicRequest, *DynamicResponse) +} + +type DynamicRequest struct { + // Path contains the path of the attribute for setting the + // default value. Use this path for any response diagnostics. + Path path.Path +} + +type DynamicResponse struct { + // Diagnostics report errors or warnings related to setting the + // default value resource configuration. An empty slice + // indicates success, with no warnings or errors generated. + Diagnostics diag.Diagnostics + + // PlanValue is the planned new state for the attribute. + PlanValue types.Dynamic +} diff --git a/resource/schema/dynamic_attribute.go b/resource/schema/dynamic_attribute.go new file mode 100644 index 000000000..7b97625d9 --- /dev/null +++ b/resource/schema/dynamic_attribute.go @@ -0,0 +1,241 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package schema + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-go/tftypes" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschema/fwxschema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/defaults" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" +) + +// Ensure the implementation satisfies the desired interfaces. +var ( + _ Attribute = DynamicAttribute{} + _ fwschema.AttributeWithValidateImplementation = DynamicAttribute{} + _ fwschema.AttributeWithDynamicDefaultValue = DynamicAttribute{} + _ fwxschema.AttributeWithDynamicPlanModifiers = DynamicAttribute{} + _ fwxschema.AttributeWithDynamicValidators = DynamicAttribute{} +) + +// DynamicAttribute represents a schema attribute that is a dynamic, rather +// than a single static type. Static types are always preferable over dynamic +// types in Terraform as practitioners will receive less helpful configuration +// assistance from validation error diagnostics and editor integrations. When +// retrieving the value for this attribute, use types.Dynamic as the value type +// unless the CustomType field is set. +// +// The concrete value type for a dynamic is determined at runtime in this order: +// 1. By Terraform, if defined in the configuration (if Required or Optional). +// 2. By the provider (if Computed). +// +// Once the concrete value type has been determined, it must remain consistent between +// plan and apply or Terraform will return an error. +type DynamicAttribute struct { + // CustomType enables the use of a custom attribute type in place of the + // default basetypes.DynamicType. When retrieving data, the basetypes.DynamicValuable + // associated with this custom type must be used in place of types.Dynamic. + CustomType basetypes.DynamicTypable + + // Required indicates whether the practitioner must enter a value for + // this attribute or not. Required and Optional cannot both be true, + // and Required and Computed cannot both be true. + Required bool + + // Optional indicates whether the practitioner can choose to enter a value + // for this attribute or not. Optional and Required cannot both be true. + Optional bool + + // Computed indicates whether the provider may return its own value for + // this Attribute or not. Required and Computed cannot both be true. If + // Required and Optional are both false, Computed must be true, and the + // attribute will be considered "read only" for the practitioner, with + // only the provider able to set its value. + Computed bool + + // Sensitive indicates whether the value of this attribute should be + // considered sensitive data. Setting it to true will obscure the value + // in CLI output. Sensitive does not impact how values are stored, and + // practitioners are encouraged to store their state as if the entire + // file is sensitive. + Sensitive bool + + // Description is used in various tooling, like the language server, to + // give practitioners more information about what this attribute is, + // what it's for, and how it should be used. It should be written as + // plain text, with no special formatting. + Description string + + // MarkdownDescription is used in various tooling, like the + // documentation generator, to give practitioners more information + // about what this attribute is, what it's for, and how it should be + // used. It should be formatted using Markdown. + MarkdownDescription string + + // DeprecationMessage defines warning diagnostic details to display when + // practitioner configurations use this Attribute. The warning diagnostic + // summary is automatically set to "Attribute Deprecated" along with + // configuration source file and line information. + // + // Set this field to a practitioner actionable message such as: + // + // - "Configure other_attribute instead. This attribute will be removed + // in the next major version of the provider." + // - "Remove this attribute's configuration as it no longer is used and + // the attribute will be removed in the next major version of the + // provider." + // + // In Terraform 1.2.7 and later, this warning diagnostic is displayed any + // time a practitioner attempts to configure a value for this attribute and + // certain scenarios where this attribute is referenced. + // + // In Terraform 1.2.6 and earlier, this warning diagnostic is only + // displayed when the Attribute is Required or Optional, and if the + // practitioner configuration sets the value to a known or unknown value + // (which may eventually be null). It has no effect when the Attribute is + // Computed-only (read-only; not Required or Optional). + // + // Across any Terraform version, there are no warnings raised for + // practitioner configuration values set directly to null, as there is no + // way for the framework to differentiate between an unset and null + // configuration due to how Terraform sends configuration information + // across the protocol. + // + // Additional information about deprecation enhancements for read-only + // attributes can be found in: + // + // - https://github.com/hashicorp/terraform/issues/7569 + // + DeprecationMessage string + + // Validators define value validation functionality for the attribute. All + // elements of the slice of AttributeValidator are run, regardless of any + // previous error diagnostics. + // + // Many common use case validators can be found in the + // github.com/hashicorp/terraform-plugin-framework-validators Go module. + // + // If the Type field points to a custom type that implements the + // xattr.TypeWithValidate interface, the validators defined in this field + // are run in addition to the validation defined by the type. + Validators []validator.Dynamic + + // PlanModifiers defines a sequence of modifiers for this attribute at + // plan time. Schema-based plan modifications occur before any + // resource-level plan modifications. + // + // Schema-based plan modifications can adjust Terraform's plan by: + // + // - Requiring resource recreation. Typically used for configuration + // updates which cannot be done in-place. + // - Setting the planned value. Typically used for enhancing the plan + // to replace unknown values. Computed must be true or Terraform will + // return an error. If the plan value is known due to a known + // configuration value, the plan value cannot be changed or Terraform + // will return an error. + // + // Any errors will prevent further execution of this sequence or modifiers. + PlanModifiers []planmodifier.Dynamic + + // Default defines a proposed new state (plan) value for the attribute + // if the configuration value is null. Default prevents the framework + // from automatically marking the value as unknown during planning when + // other proposed new state changes are detected. If the attribute is + // computed and the value could be altered by other changes then a default + // should be avoided and a plan modifier should be used instead. + Default defaults.Dynamic +} + +// ApplyTerraform5AttributePathStep always returns an error as it is not +// possible to step further into a DynamicAttribute. +func (a DynamicAttribute) ApplyTerraform5AttributePathStep(step tftypes.AttributePathStep) (interface{}, error) { + return a.GetType().ApplyTerraform5AttributePathStep(step) +} + +// Equal returns true if the given Attribute is a DynamicAttribute +// and all fields are equal. +func (a DynamicAttribute) Equal(o fwschema.Attribute) bool { + if _, ok := o.(DynamicAttribute); !ok { + return false + } + + return fwschema.AttributesEqual(a, o) +} + +// GetDeprecationMessage returns the DeprecationMessage field value. +func (a DynamicAttribute) GetDeprecationMessage() string { + return a.DeprecationMessage +} + +// GetDescription returns the Description field value. +func (a DynamicAttribute) GetDescription() string { + return a.Description +} + +// GetMarkdownDescription returns the MarkdownDescription field value. +func (a DynamicAttribute) GetMarkdownDescription() string { + return a.MarkdownDescription +} + +// GetType returns types.DynamicType or the CustomType field value if defined. +func (a DynamicAttribute) GetType() attr.Type { + if a.CustomType != nil { + return a.CustomType + } + + return types.DynamicType +} + +// IsComputed returns the Computed field value. +func (a DynamicAttribute) IsComputed() bool { + return a.Computed +} + +// IsOptional returns the Optional field value. +func (a DynamicAttribute) IsOptional() bool { + return a.Optional +} + +// IsRequired returns the Required field value. +func (a DynamicAttribute) IsRequired() bool { + return a.Required +} + +// IsSensitive returns the Sensitive field value. +func (a DynamicAttribute) IsSensitive() bool { + return a.Sensitive +} + +// DynamicDefaultValue returns the Default field value. +func (a DynamicAttribute) DynamicDefaultValue() defaults.Dynamic { + return a.Default +} + +// DynamicPlanModifiers returns the PlanModifiers field value. +func (a DynamicAttribute) DynamicPlanModifiers() []planmodifier.Dynamic { + return a.PlanModifiers +} + +// DynamicValidators returns the Validators field value. +func (a DynamicAttribute) DynamicValidators() []validator.Dynamic { + return a.Validators +} + +// ValidateImplementation contains logic for validating the +// provider-defined implementation of the attribute to prevent unexpected +// errors or panics. This logic runs during the GetProviderSchema RPC and +// should never include false positives. +func (a DynamicAttribute) ValidateImplementation(ctx context.Context, req fwschema.ValidateImplementationRequest, resp *fwschema.ValidateImplementationResponse) { + if !a.IsComputed() && a.DynamicDefaultValue() != nil { + resp.Diagnostics.Append(nonComputedAttributeWithDefaultDiag(req.Path)) + } +} diff --git a/resource/schema/dynamic_attribute_test.go b/resource/schema/dynamic_attribute_test.go new file mode 100644 index 000000000..f99dc598c --- /dev/null +++ b/resource/schema/dynamic_attribute_test.go @@ -0,0 +1,578 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package schema_test + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-go/tftypes" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" + "github.com/hashicorp/terraform-plugin-framework/internal/testing/testschema" + "github.com/hashicorp/terraform-plugin-framework/internal/testing/testtypes" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/defaults" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/dynamicdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func TestDynamicAttributeApplyTerraform5AttributePathStep(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attribute schema.DynamicAttribute + step tftypes.AttributePathStep + expected any + expectedError error + }{ + "AttributeName": { + attribute: schema.DynamicAttribute{}, + step: tftypes.AttributeName("test"), + expected: nil, + expectedError: fmt.Errorf("cannot apply AttributePathStep tftypes.AttributeName to basetypes.DynamicType"), + }, + "ElementKeyInt": { + attribute: schema.DynamicAttribute{}, + step: tftypes.ElementKeyInt(1), + expected: nil, + expectedError: fmt.Errorf("cannot apply AttributePathStep tftypes.ElementKeyInt to basetypes.DynamicType"), + }, + "ElementKeyString": { + attribute: schema.DynamicAttribute{}, + step: tftypes.ElementKeyString("test"), + expected: nil, + expectedError: fmt.Errorf("cannot apply AttributePathStep tftypes.ElementKeyString to basetypes.DynamicType"), + }, + "ElementKeyValue": { + attribute: schema.DynamicAttribute{}, + step: tftypes.ElementKeyValue(tftypes.NewValue(tftypes.String, "test")), + expected: nil, + expectedError: fmt.Errorf("cannot apply AttributePathStep tftypes.ElementKeyValue to basetypes.DynamicType"), + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, err := testCase.attribute.ApplyTerraform5AttributePathStep(testCase.step) + + if err != nil { + if testCase.expectedError == nil { + t.Fatalf("expected no error, got: %s", err) + } + + if !strings.Contains(err.Error(), testCase.expectedError.Error()) { + t.Fatalf("expected error %q, got: %s", testCase.expectedError, err) + } + } + + if err == nil && testCase.expectedError != nil { + t.Fatalf("got no error, expected: %s", testCase.expectedError) + } + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestDynamicAttributeGetDeprecationMessage(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attribute schema.DynamicAttribute + expected string + }{ + "no-deprecation-message": { + attribute: schema.DynamicAttribute{}, + expected: "", + }, + "deprecation-message": { + attribute: schema.DynamicAttribute{ + DeprecationMessage: "test deprecation message", + }, + expected: "test deprecation message", + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.attribute.GetDeprecationMessage() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestDynamicAttributeEqual(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attribute schema.DynamicAttribute + other fwschema.Attribute + expected bool + }{ + "different-type": { + attribute: schema.DynamicAttribute{}, + other: testschema.AttributeWithDynamicValidators{}, + expected: false, + }, + "equal": { + attribute: schema.DynamicAttribute{}, + other: schema.DynamicAttribute{}, + expected: true, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.attribute.Equal(testCase.other) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestDynamicAttributeGetDescription(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attribute schema.DynamicAttribute + expected string + }{ + "no-description": { + attribute: schema.DynamicAttribute{}, + expected: "", + }, + "description": { + attribute: schema.DynamicAttribute{ + Description: "test description", + }, + expected: "test description", + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.attribute.GetDescription() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestDynamicAttributeGetMarkdownDescription(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attribute schema.DynamicAttribute + expected string + }{ + "no-markdown-description": { + attribute: schema.DynamicAttribute{}, + expected: "", + }, + "markdown-description": { + attribute: schema.DynamicAttribute{ + MarkdownDescription: "test description", + }, + expected: "test description", + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.attribute.GetMarkdownDescription() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestDynamicAttributeGetType(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attribute schema.DynamicAttribute + expected attr.Type + }{ + "base": { + attribute: schema.DynamicAttribute{}, + expected: types.DynamicType, + }, + "custom-type": { + attribute: schema.DynamicAttribute{ + CustomType: testtypes.DynamicType{}, + }, + expected: testtypes.DynamicType{}, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.attribute.GetType() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestDynamicAttributeIsComputed(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attribute schema.DynamicAttribute + expected bool + }{ + "not-computed": { + attribute: schema.DynamicAttribute{}, + expected: false, + }, + "computed": { + attribute: schema.DynamicAttribute{ + Computed: true, + }, + expected: true, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.attribute.IsComputed() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestDynamicAttributeIsOptional(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attribute schema.DynamicAttribute + expected bool + }{ + "not-optional": { + attribute: schema.DynamicAttribute{}, + expected: false, + }, + "optional": { + attribute: schema.DynamicAttribute{ + Optional: true, + }, + expected: true, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.attribute.IsOptional() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestDynamicAttributeIsRequired(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attribute schema.DynamicAttribute + expected bool + }{ + "not-required": { + attribute: schema.DynamicAttribute{}, + expected: false, + }, + "required": { + attribute: schema.DynamicAttribute{ + Required: true, + }, + expected: true, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.attribute.IsRequired() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestDynamicAttributeIsSensitive(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attribute schema.DynamicAttribute + expected bool + }{ + "not-sensitive": { + attribute: schema.DynamicAttribute{}, + expected: false, + }, + "sensitive": { + attribute: schema.DynamicAttribute{ + Sensitive: true, + }, + expected: true, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.attribute.IsSensitive() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestDynamicAttributeDynamicDefaultValue(t *testing.T) { + t.Parallel() + + opt := cmp.Comparer(func(x, y defaults.Dynamic) bool { + ctx := context.Background() + req := defaults.DynamicRequest{} + + xResp := defaults.DynamicResponse{} + x.DefaultDynamic(ctx, req, &xResp) + + yResp := defaults.DynamicResponse{} + y.DefaultDynamic(ctx, req, &yResp) + + return xResp.PlanValue.Equal(yResp.PlanValue) + }) + + testCases := map[string]struct { + attribute schema.DynamicAttribute + expected defaults.Dynamic + }{ + "no-default": { + attribute: schema.DynamicAttribute{}, + expected: nil, + }, + "default": { + attribute: schema.DynamicAttribute{ + Default: dynamicdefault.StaticValue(types.DynamicValue(types.StringValue("test-value"))), + }, + expected: dynamicdefault.StaticValue(types.DynamicValue(types.StringValue("test-value"))), + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.attribute.DynamicDefaultValue() + + if diff := cmp.Diff(got, testCase.expected, opt); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestDynamicAttributeDynamicPlanModifiers(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attribute schema.DynamicAttribute + expected []planmodifier.Dynamic + }{ + "no-planmodifiers": { + attribute: schema.DynamicAttribute{}, + expected: nil, + }, + "planmodifiers": { + attribute: schema.DynamicAttribute{ + PlanModifiers: []planmodifier.Dynamic{}, + }, + expected: []planmodifier.Dynamic{}, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.attribute.DynamicPlanModifiers() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestDynamicAttributeDynamicValidators(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attribute schema.DynamicAttribute + expected []validator.Dynamic + }{ + "no-validators": { + attribute: schema.DynamicAttribute{}, + expected: nil, + }, + "validators": { + attribute: schema.DynamicAttribute{ + Validators: []validator.Dynamic{}, + }, + expected: []validator.Dynamic{}, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := testCase.attribute.DynamicValidators() + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestDynamicAttributeValidateImplementation(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + attribute schema.DynamicAttribute + request fwschema.ValidateImplementationRequest + expected *fwschema.ValidateImplementationResponse + }{ + "computed": { + attribute: schema.DynamicAttribute{ + Computed: true, + }, + request: fwschema.ValidateImplementationRequest{ + Name: "test", + Path: path.Root("test"), + }, + expected: &fwschema.ValidateImplementationResponse{}, + }, + "default-without-computed": { + attribute: schema.DynamicAttribute{ + Default: dynamicdefault.StaticValue(types.DynamicValue(types.StringValue("test"))), + }, + request: fwschema.ValidateImplementationRequest{ + Name: "test", + Path: path.Root("test"), + }, + expected: &fwschema.ValidateImplementationResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Schema Using Attribute Default For Non-Computed Attribute", + "Attribute \"test\" must be computed when using default. "+ + "This is an issue with the provider and should be reported to the provider developers.", + ), + }, + }, + }, + "default-with-computed": { + attribute: schema.DynamicAttribute{ + Computed: true, + Default: dynamicdefault.StaticValue(types.DynamicValue(types.StringValue("test"))), + }, + request: fwschema.ValidateImplementationRequest{ + Name: "test", + Path: path.Root("test"), + }, + expected: &fwschema.ValidateImplementationResponse{}, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := &fwschema.ValidateImplementationResponse{} + testCase.attribute.ValidateImplementation(context.Background(), testCase.request, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/dynamicdefault/doc.go b/resource/schema/dynamicdefault/doc.go new file mode 100644 index 000000000..209f99af9 --- /dev/null +++ b/resource/schema/dynamicdefault/doc.go @@ -0,0 +1,5 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +// Package dynamicdefault provides default values for types.Dynamic attributes. +package dynamicdefault diff --git a/resource/schema/dynamicdefault/static_value.go b/resource/schema/dynamicdefault/static_value.go new file mode 100644 index 000000000..d0a539d9f --- /dev/null +++ b/resource/schema/dynamicdefault/static_value.go @@ -0,0 +1,42 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package dynamicdefault + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/defaults" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// StaticValue returns a static dynamic value default handler. +// +// Use StaticValue if a static default value for a dynamic value should be set. +func StaticValue(defaultVal types.Dynamic) defaults.Dynamic { + return staticValueDefault{ + defaultVal: defaultVal, + } +} + +// staticValueDefault is static value default handler that +// sets a value on a dynamic attribute. +type staticValueDefault struct { + defaultVal types.Dynamic +} + +// Description returns a human-readable description of the default value handler. +func (d staticValueDefault) Description(_ context.Context) string { + return fmt.Sprintf("value defaults to %s", d.defaultVal) +} + +// MarkdownDescription returns a markdown description of the default value handler. +func (d staticValueDefault) MarkdownDescription(_ context.Context) string { + return fmt.Sprintf("value defaults to `%s`", d.defaultVal) +} + +// DefaultDynamic implements the static default value logic. +func (d staticValueDefault) DefaultDynamic(_ context.Context, req defaults.DynamicRequest, resp *defaults.DynamicResponse) { + resp.PlanValue = d.defaultVal +} diff --git a/resource/schema/dynamicdefault/static_value_test.go b/resource/schema/dynamicdefault/static_value_test.go new file mode 100644 index 000000000..d96670b51 --- /dev/null +++ b/resource/schema/dynamicdefault/static_value_test.go @@ -0,0 +1,47 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package dynamicdefault_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/defaults" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/dynamicdefault" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func TestStaticValueDefaultDynamic(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + defaultVal types.Dynamic + expected *defaults.DynamicResponse + }{ + "dynamic": { + defaultVal: types.DynamicValue(types.StringValue("test value")), + expected: &defaults.DynamicResponse{ + PlanValue: types.DynamicValue(types.StringValue("test value")), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &defaults.DynamicResponse{} + + dynamicdefault.StaticValue(testCase.defaultVal).DefaultDynamic(context.Background(), defaults.DynamicRequest{}, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/dynamicplanmodifier/doc.go b/resource/schema/dynamicplanmodifier/doc.go new file mode 100644 index 000000000..fc9382b7a --- /dev/null +++ b/resource/schema/dynamicplanmodifier/doc.go @@ -0,0 +1,5 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +// Package dynamicplanmodifier provides plan modifiers for types.Dynamic attributes. +package dynamicplanmodifier diff --git a/resource/schema/dynamicplanmodifier/requires_replace.go b/resource/schema/dynamicplanmodifier/requires_replace.go new file mode 100644 index 000000000..e4ed93ba6 --- /dev/null +++ b/resource/schema/dynamicplanmodifier/requires_replace.go @@ -0,0 +1,30 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package dynamicplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// RequiresReplace returns a plan modifier that conditionally requires +// resource replacement if: +// +// - The resource is planned for update. +// - The plan and state values are not equal. +// +// Use RequiresReplaceIfConfigured if the resource replacement should +// only occur if there is a configuration value (ignore unconfigured drift +// detection changes). Use RequiresReplaceIf if the resource replacement +// should check provider-defined conditional logic. +func RequiresReplace() planmodifier.Dynamic { + return RequiresReplaceIf( + func(_ context.Context, _ planmodifier.DynamicRequest, resp *RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true + }, + "If the value of this attribute changes, Terraform will destroy and recreate the resource.", + "If the value of this attribute changes, Terraform will destroy and recreate the resource.", + ) +} diff --git a/resource/schema/dynamicplanmodifier/requires_replace_if.go b/resource/schema/dynamicplanmodifier/requires_replace_if.go new file mode 100644 index 000000000..75eb39080 --- /dev/null +++ b/resource/schema/dynamicplanmodifier/requires_replace_if.go @@ -0,0 +1,73 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package dynamicplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// RequiresReplaceIf returns a plan modifier that conditionally requires +// resource replacement if: +// +// - The resource is planned for update. +// - The plan and state values are not equal. +// - The given function returns true. Returning false will not unset any +// prior resource replacement. +// +// Use RequiresReplace if the resource replacement should always occur on value +// changes. Use RequiresReplaceIfConfigured if the resource replacement should +// occur on value changes, but only if there is a configuration value (ignore +// unconfigured drift detection changes). +func RequiresReplaceIf(f RequiresReplaceIfFunc, description, markdownDescription string) planmodifier.Dynamic { + return requiresReplaceIfModifier{ + ifFunc: f, + description: description, + markdownDescription: markdownDescription, + } +} + +// requiresReplaceIfModifier is an plan modifier that sets RequiresReplace +// on the attribute if a given function is true. +type requiresReplaceIfModifier struct { + ifFunc RequiresReplaceIfFunc + description string + markdownDescription string +} + +// Description returns a human-readable description of the plan modifier. +func (m requiresReplaceIfModifier) Description(_ context.Context) string { + return m.description +} + +// MarkdownDescription returns a markdown description of the plan modifier. +func (m requiresReplaceIfModifier) MarkdownDescription(_ context.Context) string { + return m.markdownDescription +} + +// PlanModifyDynamic implements the plan modification logic. +func (m requiresReplaceIfModifier) PlanModifyDynamic(ctx context.Context, req planmodifier.DynamicRequest, resp *planmodifier.DynamicResponse) { + // Do not replace on resource creation. + if req.State.Raw.IsNull() { + return + } + + // Do not replace on resource destroy. + if req.Plan.Raw.IsNull() { + return + } + + // Do not replace if the plan and state values are equal. + if req.PlanValue.Equal(req.StateValue) { + return + } + + ifFuncResp := &RequiresReplaceIfFuncResponse{} + + m.ifFunc(ctx, req, ifFuncResp) + + resp.Diagnostics.Append(ifFuncResp.Diagnostics...) + resp.RequiresReplace = ifFuncResp.RequiresReplace +} diff --git a/resource/schema/dynamicplanmodifier/requires_replace_if_configured.go b/resource/schema/dynamicplanmodifier/requires_replace_if_configured.go new file mode 100644 index 000000000..139d2422e --- /dev/null +++ b/resource/schema/dynamicplanmodifier/requires_replace_if_configured.go @@ -0,0 +1,35 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package dynamicplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// RequiresReplaceIfConfigured returns a plan modifier that conditionally requires +// resource replacement if: +// +// - The resource is planned for update. +// - The plan and state values are not equal. +// - The configuration value is not null. +// +// Use RequiresReplace if the resource replacement should occur regardless of +// the presence of a configuration value. Use RequiresReplaceIf if the resource +// replacement should check provider-defined conditional logic. +func RequiresReplaceIfConfigured() planmodifier.Dynamic { + return RequiresReplaceIf( + func(_ context.Context, req planmodifier.DynamicRequest, resp *RequiresReplaceIfFuncResponse) { + // This requires checking if the underlying value is also null. + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnderlyingValueNull() { + return + } + + resp.RequiresReplace = true + }, + "If the value of this attribute is configured and changes, Terraform will destroy and recreate the resource.", + "If the value of this attribute is configured and changes, Terraform will destroy and recreate the resource.", + ) +} diff --git a/resource/schema/dynamicplanmodifier/requires_replace_if_configured_test.go b/resource/schema/dynamicplanmodifier/requires_replace_if_configured_test.go new file mode 100644 index 000000000..fc017e69e --- /dev/null +++ b/resource/schema/dynamicplanmodifier/requires_replace_if_configured_test.go @@ -0,0 +1,183 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package dynamicplanmodifier_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/dynamicplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestRequiresReplaceIfConfiguredModifierPlanModifyDynamic(t *testing.T) { + t.Parallel() + + testSchema := schema.Schema{ + Attributes: map[string]schema.Attribute{ + "testattr": schema.DynamicAttribute{}, + }, + } + + nullPlan := tfsdk.Plan{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + nil, + ), + } + + nullState := tfsdk.State{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + nil, + ), + } + + testPlan := func(value types.Dynamic) tfsdk.Plan { + tfValue, err := value.ToTerraformValue(context.Background()) + + if err != nil { + panic("ToTerraformValue error: " + err.Error()) + } + + return tfsdk.Plan{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + map[string]tftypes.Value{ + "testattr": tfValue, + }, + ), + } + } + + testState := func(value types.Dynamic) tfsdk.State { + tfValue, err := value.ToTerraformValue(context.Background()) + + if err != nil { + panic("ToTerraformValue error: " + err.Error()) + } + + return tfsdk.State{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + map[string]tftypes.Value{ + "testattr": tfValue, + }, + ), + } + } + + testCases := map[string]struct { + request planmodifier.DynamicRequest + expected *planmodifier.DynamicResponse + }{ + "state-null": { + // resource creation + request: planmodifier.DynamicRequest{ + ConfigValue: types.DynamicValue(types.StringValue("test")), + Plan: testPlan(types.DynamicValue(types.StringValue("test"))), + PlanValue: types.DynamicValue(types.StringValue("test")), + State: nullState, + StateValue: types.DynamicNull(), + }, + expected: &planmodifier.DynamicResponse{ + PlanValue: types.DynamicValue(types.StringValue("test")), + RequiresReplace: false, + }, + }, + "plan-null": { + // resource destroy + request: planmodifier.DynamicRequest{ + ConfigValue: types.DynamicNull(), + Plan: nullPlan, + PlanValue: types.DynamicNull(), + State: testState(types.DynamicValue(types.StringValue("test"))), + StateValue: types.DynamicValue(types.StringValue("test")), + }, + expected: &planmodifier.DynamicResponse{ + PlanValue: types.DynamicNull(), + RequiresReplace: false, + }, + }, + "planvalue-statevalue-different-configured": { + request: planmodifier.DynamicRequest{ + ConfigValue: types.DynamicValue(types.StringValue("other")), + Plan: testPlan(types.DynamicValue(types.StringValue("other"))), + PlanValue: types.DynamicValue(types.StringValue("other")), + State: testState(types.DynamicValue(types.StringValue("test"))), + StateValue: types.DynamicValue(types.StringValue("test")), + }, + expected: &planmodifier.DynamicResponse{ + PlanValue: types.DynamicValue(types.StringValue("other")), + RequiresReplace: true, + }, + }, + "planvalue-statevalue-different-unconfigured": { + request: planmodifier.DynamicRequest{ + ConfigValue: types.DynamicNull(), + Plan: testPlan(types.DynamicValue(types.StringValue("other"))), + PlanValue: types.DynamicValue(types.StringValue("other")), + State: testState(types.DynamicValue(types.StringValue("test"))), + StateValue: types.DynamicValue(types.StringValue("test")), + }, + expected: &planmodifier.DynamicResponse{ + PlanValue: types.DynamicValue(types.StringValue("other")), + RequiresReplace: false, + }, + }, + "planvalue-statevalue-different-unconfigured-underlying-value": { + request: planmodifier.DynamicRequest{ + ConfigValue: types.DynamicValue(types.StringNull()), + Plan: testPlan(types.DynamicValue(types.StringValue("other"))), + PlanValue: types.DynamicValue(types.StringValue("other")), + State: testState(types.DynamicValue(types.StringValue("test"))), + StateValue: types.DynamicValue(types.StringValue("test")), + }, + expected: &planmodifier.DynamicResponse{ + PlanValue: types.DynamicValue(types.StringValue("other")), + RequiresReplace: false, + }, + }, + "planvalue-statevalue-equal": { + request: planmodifier.DynamicRequest{ + ConfigValue: types.DynamicValue(types.StringValue("test")), + Plan: testPlan(types.DynamicValue(types.StringValue("test"))), + PlanValue: types.DynamicValue(types.StringValue("test")), + State: testState(types.DynamicValue(types.StringValue("test"))), + StateValue: types.DynamicValue(types.StringValue("test")), + }, + expected: &planmodifier.DynamicResponse{ + PlanValue: types.DynamicValue(types.StringValue("test")), + RequiresReplace: false, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.DynamicResponse{ + PlanValue: testCase.request.PlanValue, + } + + dynamicplanmodifier.RequiresReplaceIfConfigured().PlanModifyDynamic(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/dynamicplanmodifier/requires_replace_if_func.go b/resource/schema/dynamicplanmodifier/requires_replace_if_func.go new file mode 100644 index 000000000..2baa744c6 --- /dev/null +++ b/resource/schema/dynamicplanmodifier/requires_replace_if_func.go @@ -0,0 +1,25 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package dynamicplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// RequiresReplaceIfFunc is a conditional function used in the RequiresReplaceIf +// plan modifier to determine whether the attribute requires replacement. +type RequiresReplaceIfFunc func(context.Context, planmodifier.DynamicRequest, *RequiresReplaceIfFuncResponse) + +// RequiresReplaceIfFuncResponse is the response type for a RequiresReplaceIfFunc. +type RequiresReplaceIfFuncResponse struct { + // Diagnostics report errors or warnings related to this logic. An empty + // or unset slice indicates success, with no warnings or errors generated. + Diagnostics diag.Diagnostics + + // RequiresReplace should be enabled if the resource should be replaced. + RequiresReplace bool +} diff --git a/resource/schema/dynamicplanmodifier/requires_replace_if_test.go b/resource/schema/dynamicplanmodifier/requires_replace_if_test.go new file mode 100644 index 000000000..c482bcab8 --- /dev/null +++ b/resource/schema/dynamicplanmodifier/requires_replace_if_test.go @@ -0,0 +1,181 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package dynamicplanmodifier_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/dynamicplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestRequiresReplaceIfModifierPlanModifyDynamic(t *testing.T) { + t.Parallel() + + testSchema := schema.Schema{ + Attributes: map[string]schema.Attribute{ + "testattr": schema.DynamicAttribute{}, + }, + } + + nullPlan := tfsdk.Plan{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + nil, + ), + } + + nullState := tfsdk.State{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + nil, + ), + } + + testPlan := func(value types.Dynamic) tfsdk.Plan { + tfValue, err := value.ToTerraformValue(context.Background()) + + if err != nil { + panic("ToTerraformValue error: " + err.Error()) + } + + return tfsdk.Plan{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + map[string]tftypes.Value{ + "testattr": tfValue, + }, + ), + } + } + + testState := func(value types.Dynamic) tfsdk.State { + tfValue, err := value.ToTerraformValue(context.Background()) + + if err != nil { + panic("ToTerraformValue error: " + err.Error()) + } + + return tfsdk.State{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + map[string]tftypes.Value{ + "testattr": tfValue, + }, + ), + } + } + + testCases := map[string]struct { + request planmodifier.DynamicRequest + ifFunc dynamicplanmodifier.RequiresReplaceIfFunc + expected *planmodifier.DynamicResponse + }{ + "state-null": { + // resource creation + request: planmodifier.DynamicRequest{ + Plan: testPlan(types.DynamicUnknown()), + PlanValue: types.DynamicUnknown(), + State: nullState, + StateValue: types.DynamicNull(), + }, + ifFunc: func(ctx context.Context, req planmodifier.DynamicRequest, resp *dynamicplanmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true // should never reach here + }, + expected: &planmodifier.DynamicResponse{ + PlanValue: types.DynamicUnknown(), + RequiresReplace: false, + }, + }, + "plan-null": { + // resource destroy + request: planmodifier.DynamicRequest{ + Plan: nullPlan, + PlanValue: types.DynamicNull(), + State: testState(types.DynamicValue(types.StringValue("test"))), + StateValue: types.DynamicValue(types.StringValue("test")), + }, + ifFunc: func(ctx context.Context, req planmodifier.DynamicRequest, resp *dynamicplanmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true // should never reach here + }, + expected: &planmodifier.DynamicResponse{ + PlanValue: types.DynamicNull(), + RequiresReplace: false, + }, + }, + "planvalue-statevalue-different-if-false": { + request: planmodifier.DynamicRequest{ + Plan: testPlan(types.DynamicValue(types.StringValue("other"))), + PlanValue: types.DynamicValue(types.StringValue("other")), + State: testState(types.DynamicValue(types.StringValue("test"))), + StateValue: types.DynamicValue(types.StringValue("test")), + }, + ifFunc: func(ctx context.Context, req planmodifier.DynamicRequest, resp *dynamicplanmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = false // no change + }, + expected: &planmodifier.DynamicResponse{ + PlanValue: types.DynamicValue(types.StringValue("other")), + RequiresReplace: false, + }, + }, + "planvalue-statevalue-different-if-true": { + request: planmodifier.DynamicRequest{ + Plan: testPlan(types.DynamicValue(types.StringValue("other"))), + PlanValue: types.DynamicValue(types.StringValue("other")), + State: testState(types.DynamicValue(types.StringValue("test"))), + StateValue: types.DynamicValue(types.StringValue("test")), + }, + ifFunc: func(ctx context.Context, req planmodifier.DynamicRequest, resp *dynamicplanmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true // should reach here + }, + expected: &planmodifier.DynamicResponse{ + PlanValue: types.DynamicValue(types.StringValue("other")), + RequiresReplace: true, + }, + }, + "planvalue-statevalue-equal": { + request: planmodifier.DynamicRequest{ + Plan: testPlan(types.DynamicValue(types.StringValue("test"))), + PlanValue: types.DynamicValue(types.StringValue("test")), + State: testState(types.DynamicValue(types.StringValue("test"))), + StateValue: types.DynamicValue(types.StringValue("test")), + }, + ifFunc: func(ctx context.Context, req planmodifier.DynamicRequest, resp *dynamicplanmodifier.RequiresReplaceIfFuncResponse) { + resp.RequiresReplace = true // should never reach here + }, + expected: &planmodifier.DynamicResponse{ + PlanValue: types.DynamicValue(types.StringValue("test")), + RequiresReplace: false, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.DynamicResponse{ + PlanValue: testCase.request.PlanValue, + } + + dynamicplanmodifier.RequiresReplaceIf(testCase.ifFunc, "test", "test").PlanModifyDynamic(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/dynamicplanmodifier/requires_replace_test.go b/resource/schema/dynamicplanmodifier/requires_replace_test.go new file mode 100644 index 000000000..2b1d76fb4 --- /dev/null +++ b/resource/schema/dynamicplanmodifier/requires_replace_test.go @@ -0,0 +1,153 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package dynamicplanmodifier_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/dynamicplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestRequiresReplaceModifierPlanModifyDynamic(t *testing.T) { + t.Parallel() + + testSchema := schema.Schema{ + Attributes: map[string]schema.Attribute{ + "testattr": schema.DynamicAttribute{}, + }, + } + + nullPlan := tfsdk.Plan{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + nil, + ), + } + + nullState := tfsdk.State{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + nil, + ), + } + + testPlan := func(value types.Dynamic) tfsdk.Plan { + tfValue, err := value.ToTerraformValue(context.Background()) + + if err != nil { + panic("ToTerraformValue error: " + err.Error()) + } + + return tfsdk.Plan{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + map[string]tftypes.Value{ + "testattr": tfValue, + }, + ), + } + } + + testState := func(value types.Dynamic) tfsdk.State { + tfValue, err := value.ToTerraformValue(context.Background()) + + if err != nil { + panic("ToTerraformValue error: " + err.Error()) + } + + return tfsdk.State{ + Schema: testSchema, + Raw: tftypes.NewValue( + testSchema.Type().TerraformType(context.Background()), + map[string]tftypes.Value{ + "testattr": tfValue, + }, + ), + } + } + + testCases := map[string]struct { + request planmodifier.DynamicRequest + expected *planmodifier.DynamicResponse + }{ + "state-null": { + // resource creation + request: planmodifier.DynamicRequest{ + Plan: testPlan(types.DynamicUnknown()), + PlanValue: types.DynamicUnknown(), + State: nullState, + StateValue: types.DynamicNull(), + }, + expected: &planmodifier.DynamicResponse{ + PlanValue: types.DynamicUnknown(), + RequiresReplace: false, + }, + }, + "plan-null": { + // resource destroy + request: planmodifier.DynamicRequest{ + Plan: nullPlan, + PlanValue: types.DynamicNull(), + State: testState(types.DynamicValue(types.StringValue("test"))), + StateValue: types.DynamicValue(types.StringValue("test")), + }, + expected: &planmodifier.DynamicResponse{ + PlanValue: types.DynamicNull(), + RequiresReplace: false, + }, + }, + "planvalue-statevalue-different": { + request: planmodifier.DynamicRequest{ + Plan: testPlan(types.DynamicValue(types.StringValue("other"))), + PlanValue: types.DynamicValue(types.StringValue("other")), + State: testState(types.DynamicValue(types.StringValue("test"))), + StateValue: types.DynamicValue(types.StringValue("test")), + }, + expected: &planmodifier.DynamicResponse{ + PlanValue: types.DynamicValue(types.StringValue("other")), + RequiresReplace: true, + }, + }, + "planvalue-statevalue-equal": { + request: planmodifier.DynamicRequest{ + Plan: testPlan(types.DynamicValue(types.StringValue("test"))), + PlanValue: types.DynamicValue(types.StringValue("test")), + State: testState(types.DynamicValue(types.StringValue("test"))), + StateValue: types.DynamicValue(types.StringValue("test")), + }, + expected: &planmodifier.DynamicResponse{ + PlanValue: types.DynamicValue(types.StringValue("test")), + RequiresReplace: false, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.DynamicResponse{ + PlanValue: testCase.request.PlanValue, + } + + dynamicplanmodifier.RequiresReplace().PlanModifyDynamic(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/dynamicplanmodifier/use_state_for_unknown.go b/resource/schema/dynamicplanmodifier/use_state_for_unknown.go new file mode 100644 index 000000000..2e3f256ec --- /dev/null +++ b/resource/schema/dynamicplanmodifier/use_state_for_unknown.go @@ -0,0 +1,58 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package dynamicplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// UseStateForUnknown returns a plan modifier that copies a known prior state +// value into the planned value. Use this when it is known that an unconfigured +// value will remain the same after a resource update. +// +// To prevent Terraform errors, the framework automatically sets unconfigured +// and Computed attributes to an unknown value "(known after apply)" on update. +// Using this plan modifier will instead display the prior state value in the +// plan, unless a prior plan modifier adjusts the value. +func UseStateForUnknown() planmodifier.Dynamic { + return useStateForUnknownModifier{} +} + +// useStateForUnknownModifier implements the plan modifier. +type useStateForUnknownModifier struct{} + +// Description returns a human-readable description of the plan modifier. +func (m useStateForUnknownModifier) Description(_ context.Context) string { + return "Once set, the value of this attribute in state will not change." +} + +// MarkdownDescription returns a markdown description of the plan modifier. +func (m useStateForUnknownModifier) MarkdownDescription(_ context.Context) string { + return "Once set, the value of this attribute in state will not change." +} + +// PlanModifyDynamic implements the plan modification logic. +func (m useStateForUnknownModifier) PlanModifyDynamic(ctx context.Context, req planmodifier.DynamicRequest, resp *planmodifier.DynamicResponse) { + // Do nothing if there is no state value. + // This also requires checking if the underlying value is null. + if req.StateValue.IsNull() || req.StateValue.IsUnderlyingValueNull() { + return + } + + // Do nothing if there is a known planned value. + // This also requires checking if the underlying value is known. + if !req.PlanValue.IsUnknown() && !req.PlanValue.IsUnderlyingValueUnknown() { + return + } + + // Do nothing if there is an unknown configuration value, otherwise interpolation gets messed up. + // This also requires checking if the underlying value is unknown. + if req.ConfigValue.IsUnknown() || req.ConfigValue.IsUnderlyingValueUnknown() { + return + } + + resp.PlanValue = req.StateValue +} diff --git a/resource/schema/dynamicplanmodifier/use_state_for_unknown_test.go b/resource/schema/dynamicplanmodifier/use_state_for_unknown_test.go new file mode 100644 index 000000000..13fbcd264 --- /dev/null +++ b/resource/schema/dynamicplanmodifier/use_state_for_unknown_test.go @@ -0,0 +1,159 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package dynamicplanmodifier_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/dynamicplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func TestUseStateForUnknownModifierPlanModifyDynamic(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + request planmodifier.DynamicRequest + expected *planmodifier.DynamicResponse + }{ + "null-state": { + // when we first create the resource, use the unknown value + request: planmodifier.DynamicRequest{ + StateValue: types.DynamicNull(), + PlanValue: types.DynamicUnknown(), + ConfigValue: types.DynamicNull(), + }, + expected: &planmodifier.DynamicResponse{ + PlanValue: types.DynamicUnknown(), + }, + }, + "null-underlying-state-value": { + // if the state value has a known underlying type, but a null underlying value, + // use the unknown value + request: planmodifier.DynamicRequest{ + StateValue: types.DynamicValue(types.StringNull()), + PlanValue: types.DynamicUnknown(), + ConfigValue: types.DynamicNull(), + }, + expected: &planmodifier.DynamicResponse{ + PlanValue: types.DynamicUnknown(), + }, + }, + "known-plan": { + // this would really only happen if we had a plan + // modifier setting the value before this plan modifier + // got to it. We still want to preserve that value, in this case + request: planmodifier.DynamicRequest{ + StateValue: types.DynamicValue(types.StringValue("other")), + PlanValue: types.DynamicValue(types.StringValue("test")), + ConfigValue: types.DynamicNull(), + }, + expected: &planmodifier.DynamicResponse{ + PlanValue: types.DynamicValue(types.StringValue("test")), + }, + }, + "known-plan-null": { + // this would really only happen if we had a plan + // modifier setting the value before this plan modifier + // got to it. We still want to preserve that value, in this case + request: planmodifier.DynamicRequest{ + StateValue: types.DynamicValue(types.StringValue("other")), + PlanValue: types.DynamicNull(), + ConfigValue: types.DynamicNull(), + }, + expected: &planmodifier.DynamicResponse{ + PlanValue: types.DynamicNull(), + }, + }, + "known-underlying-plan-value-null": { + // this would really only happen if we had a plan + // modifier setting the value before this plan modifier + // got to it. We still want to preserve that value, in this case + request: planmodifier.DynamicRequest{ + StateValue: types.DynamicValue(types.StringValue("other")), + PlanValue: types.DynamicValue(types.StringNull()), + ConfigValue: types.DynamicNull(), + }, + expected: &planmodifier.DynamicResponse{ + PlanValue: types.DynamicValue(types.StringNull()), + }, + }, + "non-null-state-unknown-plan": { + // this is the situation we want to preserve the state in + request: planmodifier.DynamicRequest{ + StateValue: types.DynamicValue(types.StringValue("test")), + PlanValue: types.DynamicUnknown(), + ConfigValue: types.DynamicNull(), + }, + expected: &planmodifier.DynamicResponse{ + PlanValue: types.DynamicValue(types.StringValue("test")), + }, + }, + "non-null-state-unknown-underlying-plan-value": { + // if the plan value has a known underlying type, but an unknown underlying value + // we want to preserve the state + request: planmodifier.DynamicRequest{ + StateValue: types.DynamicValue(types.StringValue("test")), + PlanValue: types.DynamicValue(types.StringUnknown()), + ConfigValue: types.DynamicNull(), + }, + expected: &planmodifier.DynamicResponse{ + PlanValue: types.DynamicValue(types.StringValue("test")), + }, + }, + "unknown-config": { + // this is the situation in which a user is + // interpolating into a field. We want that to still + // show up as unknown, otherwise they'll get apply-time + // errors for changing the value even though we knew it + // was legitimately possible for it to change and the + // provider can't prevent this from happening + request: planmodifier.DynamicRequest{ + StateValue: types.DynamicValue(types.StringValue("test")), + PlanValue: types.DynamicUnknown(), + ConfigValue: types.DynamicUnknown(), + }, + expected: &planmodifier.DynamicResponse{ + PlanValue: types.DynamicUnknown(), + }, + }, + "unknown-underlying-config-value": { + // this is the situation in which a user is + // interpolating into a field. We want that to still + // show up as unknown, otherwise they'll get apply-time + // errors for changing the value even though we knew it + // was legitimately possible for it to change and the + // provider can't prevent this from happening + request: planmodifier.DynamicRequest{ + StateValue: types.DynamicValue(types.StringValue("test")), + PlanValue: types.DynamicValue(types.StringUnknown()), + ConfigValue: types.DynamicValue(types.StringUnknown()), + }, + expected: &planmodifier.DynamicResponse{ + PlanValue: types.DynamicValue(types.StringUnknown()), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.DynamicResponse{ + PlanValue: testCase.request.PlanValue, + } + + dynamicplanmodifier.UseStateForUnknown().PlanModifyDynamic(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/list_attribute.go b/resource/schema/list_attribute.go index d033e8175..1dc0e0e8c 100644 --- a/resource/schema/list_attribute.go +++ b/resource/schema/list_attribute.go @@ -11,6 +11,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema/fwxschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwtype" "github.com/hashicorp/terraform-plugin-framework/resource/schema/defaults" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/schema/validator" @@ -49,6 +50,10 @@ var ( type ListAttribute struct { // ElementType is the type for all elements of the list. This field must be // set. + // + // Element types that contain a dynamic type (i.e. types.Dynamic) are not supported. + // If underlying dynamic values are required, replace this attribute definition with + // DynamicAttribute instead. ElementType attr.Type // CustomType enables the use of a custom attribute type in place of the @@ -251,6 +256,10 @@ func (a ListAttribute) ValidateImplementation(ctx context.Context, req fwschema. resp.Diagnostics.Append(fwschema.AttributeMissingElementTypeDiag(req.Path)) } + if a.CustomType == nil && fwtype.ContainsCollectionWithDynamic(a.GetType()) { + resp.Diagnostics.Append(fwtype.AttributeCollectionWithDynamicTypeDiag(req.Path)) + } + if a.ListDefaultValue() != nil { if !a.IsComputed() { resp.Diagnostics.Append(nonComputedAttributeWithDefaultDiag(req.Path)) diff --git a/resource/schema/list_attribute_test.go b/resource/schema/list_attribute_test.go index 8d80c31b6..5c15849cc 100644 --- a/resource/schema/list_attribute_test.go +++ b/resource/schema/list_attribute_test.go @@ -669,6 +669,28 @@ func TestListAttributeValidateImplementation(t *testing.T) { }, expected: &fwschema.ValidateImplementationResponse{}, }, + "elementtype-dynamic": { + attribute: schema.ListAttribute{ + Computed: true, + ElementType: types.DynamicType, + }, + request: fwschema.ValidateImplementationRequest{ + Name: "test", + Path: path.Root("test"), + }, + expected: &fwschema.ValidateImplementationResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Schema Implementation", + "When validating the schema, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "\"test\" is an attribute that contains a collection type with a nested dynamic type.\n\n"+ + "Dynamic types inside of collections are not currently supported in terraform-plugin-framework. "+ + "If underlying dynamic values are required, replace the \"test\" attribute definition with DynamicAttribute instead.", + ), + }, + }, + }, "elementtype-missing": { attribute: schema.ListAttribute{ Computed: true, diff --git a/resource/schema/list_nested_attribute.go b/resource/schema/list_nested_attribute.go index fb18c62f3..95fd2ba01 100644 --- a/resource/schema/list_nested_attribute.go +++ b/resource/schema/list_nested_attribute.go @@ -12,6 +12,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema/fwxschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwtype" "github.com/hashicorp/terraform-plugin-framework/resource/schema/defaults" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/schema/validator" @@ -58,6 +59,10 @@ var ( type ListNestedAttribute struct { // NestedObject is the underlying object that contains nested attributes. // This field must be set. + // + // Nested attributes that contain a dynamic type (i.e. DynamicAttribute) are not supported. + // If underlying dynamic values are required, replace this attribute definition with + // DynamicAttribute instead. NestedObject NestedAttributeObject // CustomType enables the use of a custom attribute type in place of the @@ -275,6 +280,10 @@ func (a ListNestedAttribute) ListValidators() []validator.List { // errors or panics. This logic runs during the GetProviderSchema RPC and // should never include false positives. func (a ListNestedAttribute) ValidateImplementation(ctx context.Context, req fwschema.ValidateImplementationRequest, resp *fwschema.ValidateImplementationResponse) { + if a.CustomType == nil && fwtype.ContainsCollectionWithDynamic(a.GetType()) { + resp.Diagnostics.Append(fwtype.AttributeCollectionWithDynamicTypeDiag(req.Path)) + } + if a.ListDefaultValue() != nil { if !a.IsComputed() { resp.Diagnostics.Append(nonComputedAttributeWithDefaultDiag(req.Path)) diff --git a/resource/schema/list_nested_attribute_test.go b/resource/schema/list_nested_attribute_test.go index 9a9928d7c..1662109aa 100644 --- a/resource/schema/list_nested_attribute_test.go +++ b/resource/schema/list_nested_attribute_test.go @@ -881,6 +881,33 @@ func TestListNestedAttributeValidateImplementation(t *testing.T) { }, }, }, + "nestedobject-dynamic": { + attribute: schema.ListNestedAttribute{ + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "test_dyn": schema.DynamicAttribute{ + Computed: true, + }, + }, + }, + }, + request: fwschema.ValidateImplementationRequest{ + Name: "test", + Path: path.Root("test"), + }, + expected: &fwschema.ValidateImplementationResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Schema Implementation", + "When validating the schema, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "\"test\" is an attribute that contains a collection type with a nested dynamic type.\n\n"+ + "Dynamic types inside of collections are not currently supported in terraform-plugin-framework. "+ + "If underlying dynamic values are required, replace the \"test\" attribute definition with DynamicAttribute instead.", + ), + }, + }, + }, } for name, testCase := range testCases { diff --git a/resource/schema/list_nested_block.go b/resource/schema/list_nested_block.go index e105d1164..b82cfea65 100644 --- a/resource/schema/list_nested_block.go +++ b/resource/schema/list_nested_block.go @@ -4,11 +4,13 @@ package schema import ( + "context" "fmt" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema/fwxschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwtype" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" @@ -18,9 +20,10 @@ import ( // Ensure the implementation satisifies the desired interfaces. var ( - _ Block = ListNestedBlock{} - _ fwxschema.BlockWithListPlanModifiers = ListNestedBlock{} - _ fwxschema.BlockWithListValidators = ListNestedBlock{} + _ Block = ListNestedBlock{} + _ fwschema.BlockWithValidateImplementation = ListNestedBlock{} + _ fwxschema.BlockWithListPlanModifiers = ListNestedBlock{} + _ fwxschema.BlockWithListValidators = ListNestedBlock{} ) // ListNestedBlock represents a block that is a list of objects where @@ -57,6 +60,10 @@ var ( type ListNestedBlock struct { // NestedObject is the underlying object that contains nested attributes or // blocks. This field must be set. + // + // Nested attributes that contain a dynamic type (i.e. DynamicAttribute) are not supported. + // If underlying dynamic values are required, replace this block definition with + // a DynamicAttribute. NestedObject NestedBlockObject // CustomType enables the use of a custom attribute type in place of the @@ -210,3 +217,13 @@ func (b ListNestedBlock) Type() attr.Type { ElemType: b.NestedObject.Type(), } } + +// ValidateImplementation contains logic for validating the +// provider-defined implementation of the block to prevent unexpected +// errors or panics. This logic runs during the GetProviderSchema RPC and +// should never include false positives. +func (b ListNestedBlock) ValidateImplementation(ctx context.Context, req fwschema.ValidateImplementationRequest, resp *fwschema.ValidateImplementationResponse) { + if b.CustomType == nil && fwtype.ContainsCollectionWithDynamic(b.Type()) { + resp.Diagnostics.Append(fwtype.BlockCollectionWithDynamicTypeDiag(req.Path)) + } +} diff --git a/resource/schema/list_nested_block_test.go b/resource/schema/list_nested_block_test.go index ecdb1def0..332a7ad19 100644 --- a/resource/schema/list_nested_block_test.go +++ b/resource/schema/list_nested_block_test.go @@ -4,14 +4,17 @@ package schema_test import ( + "context" "fmt" "strings" "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/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/testing/testschema" + "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/schema/validator" @@ -526,3 +529,56 @@ func TestListNestedBlockType(t *testing.T) { }) } } + +func TestListNestedBlockValidateImplementation(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + block schema.ListNestedBlock + request fwschema.ValidateImplementationRequest + expected *fwschema.ValidateImplementationResponse + }{ + "nestedobject-dynamic": { + block: schema.ListNestedBlock{ + NestedObject: schema.NestedBlockObject{ + Attributes: map[string]schema.Attribute{ + "test_dyn": schema.DynamicAttribute{ + Computed: true, + }, + }, + }, + }, + request: fwschema.ValidateImplementationRequest{ + Name: "test", + Path: path.Root("test"), + }, + expected: &fwschema.ValidateImplementationResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Schema Implementation", + "When validating the schema, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "\"test\" is a block that contains a collection type with a nested dynamic type.\n\n"+ + "Dynamic types inside of collections are not currently supported in terraform-plugin-framework. "+ + "If underlying dynamic values are required, replace the \"test\" block definition with a DynamicAttribute.", + ), + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := &fwschema.ValidateImplementationResponse{} + testCase.block.ValidateImplementation(context.Background(), testCase.request, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/map_attribute.go b/resource/schema/map_attribute.go index 8268b57c9..ea08fa7b2 100644 --- a/resource/schema/map_attribute.go +++ b/resource/schema/map_attribute.go @@ -11,6 +11,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema/fwxschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwtype" "github.com/hashicorp/terraform-plugin-framework/resource/schema/defaults" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/schema/validator" @@ -52,6 +53,10 @@ var ( type MapAttribute struct { // ElementType is the type for all elements of the map. This field must be // set. + // + // Element types that contain a dynamic type (i.e. types.Dynamic) are not supported. + // If underlying dynamic values are required, replace this attribute definition with + // DynamicAttribute instead. ElementType attr.Type // CustomType enables the use of a custom attribute type in place of the @@ -254,6 +259,10 @@ func (a MapAttribute) ValidateImplementation(ctx context.Context, req fwschema.V resp.Diagnostics.Append(fwschema.AttributeMissingElementTypeDiag(req.Path)) } + if a.CustomType == nil && fwtype.ContainsCollectionWithDynamic(a.GetType()) { + resp.Diagnostics.Append(fwtype.AttributeCollectionWithDynamicTypeDiag(req.Path)) + } + if a.MapDefaultValue() != nil { if !a.IsComputed() { resp.Diagnostics.Append(nonComputedAttributeWithDefaultDiag(req.Path)) diff --git a/resource/schema/map_attribute_test.go b/resource/schema/map_attribute_test.go index 5c41feaf9..e4ff058f0 100644 --- a/resource/schema/map_attribute_test.go +++ b/resource/schema/map_attribute_test.go @@ -668,6 +668,28 @@ func TestMapAttributeValidateImplementation(t *testing.T) { }, expected: &fwschema.ValidateImplementationResponse{}, }, + "elementtype-dynamic": { + attribute: schema.MapAttribute{ + Computed: true, + ElementType: types.DynamicType, + }, + request: fwschema.ValidateImplementationRequest{ + Name: "test", + Path: path.Root("test"), + }, + expected: &fwschema.ValidateImplementationResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Schema Implementation", + "When validating the schema, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "\"test\" is an attribute that contains a collection type with a nested dynamic type.\n\n"+ + "Dynamic types inside of collections are not currently supported in terraform-plugin-framework. "+ + "If underlying dynamic values are required, replace the \"test\" attribute definition with DynamicAttribute instead.", + ), + }, + }, + }, "elementtype-missing": { attribute: schema.MapAttribute{ Computed: true, diff --git a/resource/schema/map_nested_attribute.go b/resource/schema/map_nested_attribute.go index daebc4359..faa1f3276 100644 --- a/resource/schema/map_nested_attribute.go +++ b/resource/schema/map_nested_attribute.go @@ -12,6 +12,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema/fwxschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwtype" "github.com/hashicorp/terraform-plugin-framework/resource/schema/defaults" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/schema/validator" @@ -58,6 +59,10 @@ var ( type MapNestedAttribute struct { // NestedObject is the underlying object that contains nested attributes. // This field must be set. + // + // Nested attributes that contain a dynamic type (i.e. DynamicAttribute) are not supported. + // If underlying dynamic values are required, replace this attribute definition with + // DynamicAttribute instead. NestedObject NestedAttributeObject // CustomType enables the use of a custom attribute type in place of the @@ -275,6 +280,10 @@ func (a MapNestedAttribute) MapValidators() []validator.Map { // errors or panics. This logic runs during the GetProviderSchema RPC and // should never include false positives. func (a MapNestedAttribute) ValidateImplementation(ctx context.Context, req fwschema.ValidateImplementationRequest, resp *fwschema.ValidateImplementationResponse) { + if a.CustomType == nil && fwtype.ContainsCollectionWithDynamic(a.GetType()) { + resp.Diagnostics.Append(fwtype.AttributeCollectionWithDynamicTypeDiag(req.Path)) + } + if a.MapDefaultValue() != nil { if !a.IsComputed() { resp.Diagnostics.Append(nonComputedAttributeWithDefaultDiag(req.Path)) diff --git a/resource/schema/map_nested_attribute_test.go b/resource/schema/map_nested_attribute_test.go index 0a22b5bfc..ba3e1cdd7 100644 --- a/resource/schema/map_nested_attribute_test.go +++ b/resource/schema/map_nested_attribute_test.go @@ -881,6 +881,33 @@ func TestMapNestedAttributeValidateImplementation(t *testing.T) { }, }, }, + "nestedobject-dynamic": { + attribute: schema.MapNestedAttribute{ + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "test_dyn": schema.DynamicAttribute{ + Computed: true, + }, + }, + }, + }, + request: fwschema.ValidateImplementationRequest{ + Name: "test", + Path: path.Root("test"), + }, + expected: &fwschema.ValidateImplementationResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Schema Implementation", + "When validating the schema, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "\"test\" is an attribute that contains a collection type with a nested dynamic type.\n\n"+ + "Dynamic types inside of collections are not currently supported in terraform-plugin-framework. "+ + "If underlying dynamic values are required, replace the \"test\" attribute definition with DynamicAttribute instead.", + ), + }, + }, + }, } for name, testCase := range testCases { diff --git a/resource/schema/object_attribute.go b/resource/schema/object_attribute.go index 93cddfe9a..7b9fe6a56 100644 --- a/resource/schema/object_attribute.go +++ b/resource/schema/object_attribute.go @@ -11,6 +11,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema/fwxschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwtype" "github.com/hashicorp/terraform-plugin-framework/resource/schema/defaults" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/schema/validator" @@ -51,6 +52,10 @@ var ( type ObjectAttribute struct { // AttributeTypes is the mapping of underlying attribute names to attribute // types. This field must be set. + // + // Attribute types that contain a collection with a nested dynamic type (i.e. types.List[types.Dynamic]) are not supported. + // If underlying dynamic collection values are required, replace this attribute definition with + // DynamicAttribute instead. AttributeTypes map[string]attr.Type // CustomType enables the use of a custom attribute type in place of the @@ -253,6 +258,10 @@ func (a ObjectAttribute) ValidateImplementation(ctx context.Context, req fwschem resp.Diagnostics.Append(fwschema.AttributeMissingAttributeTypesDiag(req.Path)) } + if a.CustomType == nil && fwtype.ContainsCollectionWithDynamic(a.GetType()) { + resp.Diagnostics.Append(fwtype.AttributeCollectionWithDynamicTypeDiag(req.Path)) + } + if a.ObjectDefaultValue() != nil { if !a.IsComputed() { resp.Diagnostics.Append(nonComputedAttributeWithDefaultDiag(req.Path)) diff --git a/resource/schema/object_attribute_test.go b/resource/schema/object_attribute_test.go index e9a1123d9..ddc014742 100644 --- a/resource/schema/object_attribute_test.go +++ b/resource/schema/object_attribute_test.go @@ -717,6 +717,53 @@ func TestObjectAttributeValidateImplementation(t *testing.T) { }, }, }, + "attributetypes-dynamic": { + attribute: schema.ObjectAttribute{ + AttributeTypes: map[string]attr.Type{ + "test_attr": types.DynamicType, + "test_list": types.ListType{ + ElemType: types.StringType, + }, + "test_obj": types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "test_attr": types.DynamicType, + }, + }, + }, + Computed: true, + }, + request: fwschema.ValidateImplementationRequest{ + Name: "test", + Path: path.Root("test"), + }, + expected: &fwschema.ValidateImplementationResponse{}, + }, + "attributetypes-nested-collection-dynamic": { + attribute: schema.ObjectAttribute{ + AttributeTypes: map[string]attr.Type{ + "test_attr": types.ListType{ + ElemType: types.DynamicType, + }, + }, + Computed: true, + }, + request: fwschema.ValidateImplementationRequest{ + Name: "test", + Path: path.Root("test"), + }, + expected: &fwschema.ValidateImplementationResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Schema Implementation", + "When validating the schema, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "\"test\" is an attribute that contains a collection type with a nested dynamic type.\n\n"+ + "Dynamic types inside of collections are not currently supported in terraform-plugin-framework. "+ + "If underlying dynamic values are required, replace the \"test\" attribute definition with DynamicAttribute instead.", + ), + }, + }, + }, } for name, testCase := range testCases { diff --git a/resource/schema/planmodifier/dynamic.go b/resource/schema/planmodifier/dynamic.go new file mode 100644 index 000000000..cbf9a7758 --- /dev/null +++ b/resource/schema/planmodifier/dynamic.go @@ -0,0 +1,88 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package planmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/internal/privatestate" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// Dynamic is a schema plan modifier for types.Dynamic attributes. +type Dynamic interface { + Describer + + // PlanModifyDynamic should perform the modification. + PlanModifyDynamic(context.Context, DynamicRequest, *DynamicResponse) +} + +// DynamicRequest is a request for types.Dynamic schema plan modification. +type DynamicRequest struct { + // Path contains the path of the attribute for modification. Use this path + // for any response diagnostics. + Path path.Path + + // PathExpression contains the expression matching the exact path + // of the attribute for modification. + PathExpression path.Expression + + // Config contains the entire configuration of the resource. + Config tfsdk.Config + + // ConfigValue contains the value of the attribute for modification from the configuration. + ConfigValue types.Dynamic + + // Plan contains the entire proposed new state of the resource. + Plan tfsdk.Plan + + // PlanValue contains the value of the attribute for modification from the proposed new state. + PlanValue types.Dynamic + + // State contains the entire prior state of the resource. + State tfsdk.State + + // StateValue contains the value of the attribute for modification from the prior state. + StateValue types.Dynamic + + // Private is provider-defined resource private state data which was previously + // stored with the resource state. This data is opaque to Terraform and does + // not affect plan output. Any existing data is copied to + // DynamicResponse.Private to prevent accidental private state data loss. + // + // The private state data is always the original data when the schema-based plan + // modification began or, is updated as the logic traverses deeper into underlying + // attributes. + // + // Use the GetKey method to read data. Use the SetKey method on + // DynamicResponse.Private to update or remove a value. + Private *privatestate.ProviderData +} + +// DynamicResponse is a response to a DynamicRequest. +type DynamicResponse struct { + // PlanValue is the planned new state for the attribute. + PlanValue types.Dynamic + + // RequiresReplace indicates whether a change in the attribute + // requires replacement of the whole resource. + RequiresReplace bool + + // Private is the private state resource data following the PlanModifyDynamic operation. + // This field is pre-populated from DynamicRequest.Private and + // can be modified during the resource's PlanModifyDynamic operation. + // + // The private state data is always the original data when the schema-based plan + // modification began or, is updated as the logic traverses deeper into underlying + // attributes. + Private *privatestate.ProviderData + + // Diagnostics report errors or warnings related to modifying the resource + // plan. An empty slice indicates success, with no warnings or + // errors generated. + Diagnostics diag.Diagnostics +} diff --git a/resource/schema/set_attribute.go b/resource/schema/set_attribute.go index 80d551131..7a54221bb 100644 --- a/resource/schema/set_attribute.go +++ b/resource/schema/set_attribute.go @@ -11,6 +11,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema/fwxschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwtype" "github.com/hashicorp/terraform-plugin-framework/resource/schema/defaults" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/schema/validator" @@ -47,6 +48,10 @@ var ( type SetAttribute struct { // ElementType is the type for all elements of the set. This field must be // set. + // + // Element types that contain a dynamic type (i.e. types.Dynamic) are not supported. + // If underlying dynamic values are required, replace this attribute definition with + // DynamicAttribute instead. ElementType attr.Type // CustomType enables the use of a custom attribute type in place of the @@ -249,6 +254,10 @@ func (a SetAttribute) ValidateImplementation(ctx context.Context, req fwschema.V resp.Diagnostics.Append(fwschema.AttributeMissingElementTypeDiag(req.Path)) } + if a.CustomType == nil && fwtype.ContainsCollectionWithDynamic(a.GetType()) { + resp.Diagnostics.Append(fwtype.AttributeCollectionWithDynamicTypeDiag(req.Path)) + } + if a.SetDefaultValue() != nil { if !a.IsComputed() { resp.Diagnostics.Append(nonComputedAttributeWithDefaultDiag(req.Path)) diff --git a/resource/schema/set_attribute_test.go b/resource/schema/set_attribute_test.go index 5127c88b0..c5675e820 100644 --- a/resource/schema/set_attribute_test.go +++ b/resource/schema/set_attribute_test.go @@ -657,6 +657,28 @@ func TestSetAttributeValidateImplementation(t *testing.T) { }, expected: &fwschema.ValidateImplementationResponse{}, }, + "elementtype-dynamic": { + attribute: schema.SetAttribute{ + Computed: true, + ElementType: types.DynamicType, + }, + request: fwschema.ValidateImplementationRequest{ + Name: "test", + Path: path.Root("test"), + }, + expected: &fwschema.ValidateImplementationResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Schema Implementation", + "When validating the schema, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "\"test\" is an attribute that contains a collection type with a nested dynamic type.\n\n"+ + "Dynamic types inside of collections are not currently supported in terraform-plugin-framework. "+ + "If underlying dynamic values are required, replace the \"test\" attribute definition with DynamicAttribute instead.", + ), + }, + }, + }, "elementtype-missing": { attribute: schema.SetAttribute{ Computed: true, diff --git a/resource/schema/set_nested_attribute.go b/resource/schema/set_nested_attribute.go index 191991604..3f2b3d1bb 100644 --- a/resource/schema/set_nested_attribute.go +++ b/resource/schema/set_nested_attribute.go @@ -12,6 +12,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema/fwxschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwtype" "github.com/hashicorp/terraform-plugin-framework/resource/schema/defaults" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/schema/validator" @@ -53,6 +54,10 @@ var ( type SetNestedAttribute struct { // NestedObject is the underlying object that contains nested attributes. // This field must be set. + // + // Nested attributes that contain a dynamic type (i.e. DynamicAttribute) are not supported. + // If underlying dynamic values are required, replace this attribute definition with + // DynamicAttribute instead. NestedObject NestedAttributeObject // CustomType enables the use of a custom attribute type in place of the @@ -270,6 +275,10 @@ func (a SetNestedAttribute) SetValidators() []validator.Set { // errors or panics. This logic runs during the GetProviderSchema RPC and // should never include false positives. func (a SetNestedAttribute) ValidateImplementation(ctx context.Context, req fwschema.ValidateImplementationRequest, resp *fwschema.ValidateImplementationResponse) { + if a.CustomType == nil && fwtype.ContainsCollectionWithDynamic(a.GetType()) { + resp.Diagnostics.Append(fwtype.AttributeCollectionWithDynamicTypeDiag(req.Path)) + } + if a.SetDefaultValue() != nil { if !a.IsComputed() { resp.Diagnostics.Append(nonComputedAttributeWithDefaultDiag(req.Path)) diff --git a/resource/schema/set_nested_attribute_test.go b/resource/schema/set_nested_attribute_test.go index 6eae45b3f..109921d00 100644 --- a/resource/schema/set_nested_attribute_test.go +++ b/resource/schema/set_nested_attribute_test.go @@ -881,6 +881,33 @@ func TestSetNestedAttributeValidateImplementation(t *testing.T) { }, }, }, + "nestedobject-dynamic": { + attribute: schema.SetNestedAttribute{ + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "test_dyn": schema.DynamicAttribute{ + Computed: true, + }, + }, + }, + }, + request: fwschema.ValidateImplementationRequest{ + Name: "test", + Path: path.Root("test"), + }, + expected: &fwschema.ValidateImplementationResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Schema Implementation", + "When validating the schema, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "\"test\" is an attribute that contains a collection type with a nested dynamic type.\n\n"+ + "Dynamic types inside of collections are not currently supported in terraform-plugin-framework. "+ + "If underlying dynamic values are required, replace the \"test\" attribute definition with DynamicAttribute instead.", + ), + }, + }, + }, } for name, testCase := range testCases { diff --git a/resource/schema/set_nested_block.go b/resource/schema/set_nested_block.go index 025cb15a8..78de8afb1 100644 --- a/resource/schema/set_nested_block.go +++ b/resource/schema/set_nested_block.go @@ -4,11 +4,13 @@ package schema import ( + "context" "fmt" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/fwschema/fwxschema" + "github.com/hashicorp/terraform-plugin-framework/internal/fwtype" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" @@ -18,9 +20,10 @@ import ( // Ensure the implementation satisifies the desired interfaces. var ( - _ Block = SetNestedBlock{} - _ fwxschema.BlockWithSetPlanModifiers = SetNestedBlock{} - _ fwxschema.BlockWithSetValidators = SetNestedBlock{} + _ Block = SetNestedBlock{} + _ fwschema.BlockWithValidateImplementation = SetNestedBlock{} + _ fwxschema.BlockWithSetPlanModifiers = SetNestedBlock{} + _ fwxschema.BlockWithSetValidators = SetNestedBlock{} ) // SetNestedBlock represents a block that is a set of objects where @@ -57,6 +60,10 @@ var ( type SetNestedBlock struct { // NestedObject is the underlying object that contains nested attributes or // blocks. This field must be set. + // + // Nested attributes that contain a dynamic type (i.e. DynamicAttribute) are not supported. + // If underlying dynamic values are required, replace this block definition with + // a DynamicAttribute. NestedObject NestedBlockObject // CustomType enables the use of a custom attribute type in place of the @@ -210,3 +217,13 @@ func (b SetNestedBlock) Type() attr.Type { ElemType: b.NestedObject.Type(), } } + +// ValidateImplementation contains logic for validating the +// provider-defined implementation of the block to prevent unexpected +// errors or panics. This logic runs during the GetProviderSchema RPC and +// should never include false positives. +func (b SetNestedBlock) ValidateImplementation(ctx context.Context, req fwschema.ValidateImplementationRequest, resp *fwschema.ValidateImplementationResponse) { + if b.CustomType == nil && fwtype.ContainsCollectionWithDynamic(b.Type()) { + resp.Diagnostics.Append(fwtype.BlockCollectionWithDynamicTypeDiag(req.Path)) + } +} diff --git a/resource/schema/set_nested_block_test.go b/resource/schema/set_nested_block_test.go index b8deb8fa1..f90edb279 100644 --- a/resource/schema/set_nested_block_test.go +++ b/resource/schema/set_nested_block_test.go @@ -4,14 +4,17 @@ package schema_test import ( + "context" "fmt" "strings" "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/internal/fwschema" "github.com/hashicorp/terraform-plugin-framework/internal/testing/testschema" + "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/schema/validator" @@ -526,3 +529,56 @@ func TestSetNestedBlockType(t *testing.T) { }) } } + +func TestSetNestedBlockValidateImplementation(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + block schema.SetNestedBlock + request fwschema.ValidateImplementationRequest + expected *fwschema.ValidateImplementationResponse + }{ + "nestedobject-dynamic": { + block: schema.SetNestedBlock{ + NestedObject: schema.NestedBlockObject{ + Attributes: map[string]schema.Attribute{ + "test_dyn": schema.DynamicAttribute{ + Computed: true, + }, + }, + }, + }, + request: fwschema.ValidateImplementationRequest{ + Name: "test", + Path: path.Root("test"), + }, + expected: &fwschema.ValidateImplementationResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Schema Implementation", + "When validating the schema, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "\"test\" is a block that contains a collection type with a nested dynamic type.\n\n"+ + "Dynamic types inside of collections are not currently supported in terraform-plugin-framework. "+ + "If underlying dynamic values are required, replace the \"test\" block definition with a DynamicAttribute.", + ), + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := &fwschema.ValidateImplementationResponse{} + testCase.block.ValidateImplementation(context.Background(), testCase.request, got) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/schema/validator/dynamic.go b/schema/validator/dynamic.go new file mode 100644 index 000000000..b035175a1 --- /dev/null +++ b/schema/validator/dynamic.go @@ -0,0 +1,46 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package validator + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// Dynamic is a schema validator for types.Dynamic attributes. +type Dynamic interface { + Describer + + // ValidateDynamic should perform the validation. + ValidateDynamic(context.Context, DynamicRequest, *DynamicResponse) +} + +// DynamicRequest is a request for types.Dynamic schema validation. +type DynamicRequest struct { + // Path contains the path of the attribute for validation. Use this path + // for any response diagnostics. + Path path.Path + + // PathExpression contains the expression matching the exact path + // of the attribute for validation. + PathExpression path.Expression + + // Config contains the entire configuration of the data source, provider, or resource. + Config tfsdk.Config + + // ConfigValue contains the value of the attribute for validation from the configuration. + ConfigValue types.Dynamic +} + +// DynamicResponse is a response to a DynamicRequest. +type DynamicResponse struct { + // Diagnostics report errors or warnings related to validating the data source, provider, or resource + // configuration. An empty slice indicates success, with no warnings + // or errors generated. + Diagnostics diag.Diagnostics +} diff --git a/types/basetypes/dynamic_type.go b/types/basetypes/dynamic_type.go new file mode 100644 index 000000000..87138984a --- /dev/null +++ b/types/basetypes/dynamic_type.go @@ -0,0 +1,198 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package basetypes + +import ( + "context" + "errors" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +// DynamicTypable extends attr.Type for dynamic types. Implement this interface to create a custom DynamicType type. +type DynamicTypable interface { + attr.Type + + // ValueFromDynamic should convert the DynamicValue to a DynamicValuable type. + ValueFromDynamic(context.Context, DynamicValue) (DynamicValuable, diag.Diagnostics) +} + +var _ DynamicTypable = DynamicType{} + +// DynamicType is the base framework type for a dynamic. Static types are always +// preferable over dynamic types in Terraform as practitioners will receive less +// helpful configuration assistance from validation error diagnostics and editor +// integrations. +// +// DynamicValue is the associated value type and, when known, contains a concrete +// value of another framework type. (StringValue, ListValue, ObjectValue, MapValue, etc.) +type DynamicType struct{} + +// ApplyTerraform5AttributePathStep applies the given AttributePathStep to the type. +func (t DynamicType) ApplyTerraform5AttributePathStep(step tftypes.AttributePathStep) (interface{}, error) { + // MAINTAINER NOTE: Based on dynamic type alone, there is no alternative type information to return related to a path step. + // When working with dynamics, we should always use DynamicValue to determine underlying type information. + return nil, fmt.Errorf("cannot apply AttributePathStep %T to %s", step, t.String()) +} + +// Equal returns true if the given type is equivalent. +// +// Dynamic types do not contain a reference to the underlying `attr.Value` type, so this equality check +// only asserts that both types are DynamicType. +func (t DynamicType) Equal(o attr.Type) bool { + _, ok := o.(DynamicType) + + return ok +} + +// String returns a human-friendly description of the DynamicType. +func (t DynamicType) String() string { + return "basetypes.DynamicType" +} + +// TerraformType returns the tftypes.Type that should be used to represent this type. +func (t DynamicType) TerraformType(ctx context.Context) tftypes.Type { + return tftypes.DynamicPseudoType +} + +// ValueFromDynamic returns a DynamicValuable type given a DynamicValue. +func (t DynamicType) ValueFromDynamic(ctx context.Context, v DynamicValue) (DynamicValuable, diag.Diagnostics) { + return v, nil +} + +// ValueFromTerraform returns an attr.Value given a tftypes.Value. This is meant to convert +// the tftypes.Value into a more convenient Go type for the provider to consume the data with. +func (t DynamicType) ValueFromTerraform(ctx context.Context, in tftypes.Value) (attr.Value, error) { + if in.Type() == nil { + return NewDynamicNull(), nil + } + + // For dynamic values, it's possible the incoming value is unknown but the concrete type itself is known. In this + // situation, we can't return a dynamic unknown as we will lose that concrete type information. If the type here + // is not dynamic, then we use the concrete `(attr.Type).ValueFromTerraform` below to produce the unknown value. + if !in.IsKnown() && in.Type().Is(tftypes.DynamicPseudoType) { + return NewDynamicUnknown(), nil + } + + // For dynamic values, it's possible the incoming value is null but the concrete type itself is known. In this + // situation, we can't return a dynamic null as we will lose that concrete type information. If the type here + // is not dynamic, then we use the concrete `(attr.Type).ValueFromTerraform` below to produce the null value. + if in.IsNull() && in.Type().Is(tftypes.DynamicPseudoType) { + return NewDynamicNull(), nil + } + + // MAINTAINER NOTE: It should not be possible for Terraform core to send a known value of `tftypes.DynamicPseudoType`. + // This check prevents an infinite recursion that would result if this scenario occurs when attempting to create a dynamic value. + if in.Type().Is(tftypes.DynamicPseudoType) { + return nil, errors.New("ambiguous known value for `tftypes.DynamicPseudoType` detected") + } + + attrType, err := tftypeToFrameworkType(in.Type()) + if err != nil { + return nil, err + } + + val, err := attrType.ValueFromTerraform(ctx, in) + if err != nil { + return nil, err + } + + return NewDynamicValue(val), nil +} + +// ValueType returns the Value type. +func (t DynamicType) ValueType(_ context.Context) attr.Value { + return DynamicValue{} +} + +// tftypeToFrameworkType is a helper function that returns the framework type equivalent for a given Terraform type. +// +// Custom dynamic type implementations shouldn't need to override this method, but if needed, they can implement similar logic +// in their `ValueFromTerraform` implementation. +func tftypeToFrameworkType(in tftypes.Type) (attr.Type, error) { + // Primitive types + if in.Is(tftypes.Bool) { + return BoolType{}, nil + } + if in.Is(tftypes.Number) { + return NumberType{}, nil + } + if in.Is(tftypes.String) { + return StringType{}, nil + } + + if in.Is(tftypes.DynamicPseudoType) { + // Null and Unknown values that do not have a type determined will have a type of DynamicPseudoType + return DynamicType{}, nil + } + + // Collection types + if in.Is(tftypes.List{}) { + //nolint:forcetypeassert // Type assertion is guaranteed by the above `(tftypes.Type).Is` function + l := in.(tftypes.List) + + elemType, err := tftypeToFrameworkType(l.ElementType) + if err != nil { + return nil, err + } + return ListType{ElemType: elemType}, nil + } + if in.Is(tftypes.Map{}) { + //nolint:forcetypeassert // Type assertion is guaranteed by the above `(tftypes.Type).Is` function + m := in.(tftypes.Map) + + elemType, err := tftypeToFrameworkType(m.ElementType) + if err != nil { + return nil, err + } + + return MapType{ElemType: elemType}, nil + } + if in.Is(tftypes.Set{}) { + //nolint:forcetypeassert // Type assertion is guaranteed by the above `(tftypes.Type).Is` function + s := in.(tftypes.Set) + + elemType, err := tftypeToFrameworkType(s.ElementType) + if err != nil { + return nil, err + } + + return SetType{ElemType: elemType}, nil + } + + // Structural types + if in.Is(tftypes.Object{}) { + //nolint:forcetypeassert // Type assertion is guaranteed by the above `(tftypes.Type).Is` function + o := in.(tftypes.Object) + + attrTypes := make(map[string]attr.Type, len(o.AttributeTypes)) + for name, tfType := range o.AttributeTypes { + t, err := tftypeToFrameworkType(tfType) + if err != nil { + return nil, err + } + attrTypes[name] = t + } + return ObjectType{AttrTypes: attrTypes}, nil + } + if in.Is(tftypes.Tuple{}) { + //nolint:forcetypeassert // Type assertion is guaranteed by the above `(tftypes.Type).Is` function + tup := in.(tftypes.Tuple) + + elemTypes := make([]attr.Type, len(tup.ElementTypes)) + for i, tfType := range tup.ElementTypes { + t, err := tftypeToFrameworkType(tfType) + if err != nil { + return nil, err + } + elemTypes[i] = t + } + return TupleType{ElemTypes: elemTypes}, nil + } + + return nil, fmt.Errorf("unsupported tftypes.Type detected: %T", in) +} diff --git a/types/basetypes/dynamic_type_test.go b/types/basetypes/dynamic_type_test.go new file mode 100644 index 000000000..279a7d3d3 --- /dev/null +++ b/types/basetypes/dynamic_type_test.go @@ -0,0 +1,443 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package basetypes + +import ( + "context" + "math/big" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestDynamicTypeEqual(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + receiver DynamicType + input attr.Type + expected bool + }{ + "equal": { + receiver: DynamicType{}, + input: DynamicType{}, + expected: true, + }, + "wrong-type": { + receiver: DynamicType{}, + input: StringType{}, + expected: false, + }, + "nil": { + receiver: DynamicType{}, + input: nil, + expected: false, + }, + } + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := test.receiver.Equal(test.input) + if test.expected != got { + t.Errorf("Expected %v, got %v", test.expected, got) + } + }) + } +} + +func TestDynamicTypeValueFromTerraform(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + input tftypes.Value + expected attr.Value + expectedErr string + }{ + "nil-type-to-dynamic": { + input: tftypes.Value{}, + expected: NewDynamicNull(), + }, + "dynamic-bool-to-dynamic": { + input: tftypes.NewValue(tftypes.DynamicPseudoType, true), + expectedErr: "ambiguous known value for `tftypes.DynamicPseudoType` detected", + }, + "null-to-dynamic": { + input: tftypes.NewValue(tftypes.DynamicPseudoType, nil), + expected: NewDynamicNull(), + }, + "unknown-to-dynamic": { + input: tftypes.NewValue(tftypes.DynamicPseudoType, tftypes.UnknownValue), + expected: NewDynamicUnknown(), + }, + "null-value-known-type-to-dynamic": { + input: tftypes.NewValue(tftypes.Bool, nil), + expected: NewDynamicValue(NewBoolNull()), + }, + "unknown-value-known-type-to-dynamic": { + input: tftypes.NewValue(tftypes.List{ElementType: tftypes.String}, tftypes.UnknownValue), + expected: NewDynamicValue(NewListUnknown(StringType{})), + }, + "bool-to-dynamic": { + input: tftypes.NewValue(tftypes.Bool, true), + expected: NewDynamicValue(NewBoolValue(true)), + }, + "number-to-dynamic": { + input: tftypes.NewValue(tftypes.Number, big.NewFloat(1.2345)), + expected: NewDynamicValue(NewNumberValue(big.NewFloat(1.2345))), + }, + "string-to-dynamic": { + input: tftypes.NewValue(tftypes.String, "hello world"), + expected: NewDynamicValue(NewStringValue("hello world")), + }, + "list-to-dynamic": { + input: tftypes.NewValue( + tftypes.List{ + ElementType: tftypes.Bool, + }, []tftypes.Value{ + tftypes.NewValue(tftypes.Bool, true), + tftypes.NewValue(tftypes.Bool, false), + tftypes.NewValue(tftypes.Bool, true), + }, + ), + expected: NewDynamicValue( + NewListValueMust( + BoolType{}, + []attr.Value{ + NewBoolValue(true), + NewBoolValue(false), + NewBoolValue(true), + }, + ), + ), + }, + "set-to-dynamic": { + input: tftypes.NewValue( + tftypes.Set{ + ElementType: tftypes.Number, + }, []tftypes.Value{ + tftypes.NewValue(tftypes.Number, big.NewFloat(1.2345)), + tftypes.NewValue(tftypes.Number, big.NewFloat(678)), + tftypes.NewValue(tftypes.Number, big.NewFloat(9.1)), + }, + ), + expected: NewDynamicValue( + NewSetValueMust( + NumberType{}, + []attr.Value{ + NewNumberValue(big.NewFloat(1.2345)), + NewNumberValue(big.NewFloat(678)), + NewNumberValue(big.NewFloat(9.1)), + }, + ), + ), + }, + "map-to-dynamic": { + input: tftypes.NewValue( + tftypes.Map{ + ElementType: tftypes.String, + }, map[string]tftypes.Value{ + "key1": tftypes.NewValue(tftypes.String, "hello"), + "key2": tftypes.NewValue(tftypes.String, "world"), + }, + ), + expected: NewDynamicValue( + NewMapValueMust( + StringType{}, + map[string]attr.Value{ + "key1": NewStringValue("hello"), + "key2": NewStringValue("world"), + }, + ), + ), + }, + "object-to-dynamic": { + input: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr1": tftypes.Bool, + "attr2": tftypes.String, + "attr3": tftypes.Number, + "attr4": tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "nested_attr1": tftypes.String, + }, + }, + }, + }, map[string]tftypes.Value{ + "attr1": tftypes.NewValue(tftypes.Bool, true), + "attr2": tftypes.NewValue(tftypes.String, "hello"), + "attr3": tftypes.NewValue(tftypes.Number, big.NewFloat(9.1)), + "attr4": tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "nested_attr1": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "nested_attr1": tftypes.NewValue(tftypes.String, "world"), + }), + }, + ), + expected: NewDynamicValue( + NewObjectValueMust( + map[string]attr.Type{ + "attr1": BoolType{}, + "attr2": StringType{}, + "attr3": NumberType{}, + "attr4": ObjectType{ + AttrTypes: map[string]attr.Type{ + "nested_attr1": StringType{}, + }, + }, + }, + map[string]attr.Value{ + "attr1": NewBoolValue(true), + "attr2": NewStringValue("hello"), + "attr3": NewNumberValue(big.NewFloat(9.1)), + "attr4": NewObjectValueMust( + map[string]attr.Type{ + "nested_attr1": StringType{}, + }, + map[string]attr.Value{ + "nested_attr1": NewStringValue("world"), + }, + ), + }, + ), + ), + }, + "object-with-dpt-null-to-dynamic": { + input: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr1": tftypes.Bool, + "attr2": tftypes.String, + "attr3": tftypes.Number, + "attr4": tftypes.DynamicPseudoType, + }, + }, map[string]tftypes.Value{ + "attr1": tftypes.NewValue(tftypes.Bool, nil), + "attr2": tftypes.NewValue(tftypes.String, "hello"), + "attr3": tftypes.NewValue(tftypes.Number, big.NewFloat(9.1)), + "attr4": tftypes.NewValue(tftypes.DynamicPseudoType, nil), + }, + ), + expected: NewDynamicValue( + NewObjectValueMust( + map[string]attr.Type{ + "attr1": BoolType{}, + "attr2": StringType{}, + "attr3": NumberType{}, + "attr4": DynamicType{}, + }, + map[string]attr.Value{ + "attr1": NewBoolNull(), + "attr2": NewStringValue("hello"), + "attr3": NewNumberValue(big.NewFloat(9.1)), + "attr4": NewDynamicNull(), + }, + ), + ), + }, + "object-with-dpt-unknown-to-dynamic": { + input: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr1": tftypes.Bool, + "attr2": tftypes.String, + "attr3": tftypes.Number, + "attr4": tftypes.DynamicPseudoType, + }, + }, map[string]tftypes.Value{ + "attr1": tftypes.NewValue(tftypes.Bool, nil), + "attr2": tftypes.NewValue(tftypes.String, "hello"), + "attr3": tftypes.NewValue(tftypes.Number, big.NewFloat(9.1)), + "attr4": tftypes.NewValue(tftypes.DynamicPseudoType, tftypes.UnknownValue), + }, + ), + expected: NewDynamicValue( + NewObjectValueMust( + map[string]attr.Type{ + "attr1": BoolType{}, + "attr2": StringType{}, + "attr3": NumberType{}, + "attr4": DynamicType{}, + }, + map[string]attr.Value{ + "attr1": NewBoolNull(), + "attr2": NewStringValue("hello"), + "attr3": NewNumberValue(big.NewFloat(9.1)), + "attr4": NewDynamicUnknown(), + }, + ), + ), + }, + "tuple-to-dynamic": { + input: tftypes.NewValue( + tftypes.Tuple{ + ElementTypes: []tftypes.Type{ + tftypes.Bool, + tftypes.String, + tftypes.Number, + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "nested_attr1": tftypes.String, + }, + }, + }, + }, []tftypes.Value{ + tftypes.NewValue(tftypes.Bool, nil), + tftypes.NewValue(tftypes.String, "hello"), + tftypes.NewValue(tftypes.Number, big.NewFloat(9.1)), + tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "nested_attr1": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "nested_attr1": tftypes.NewValue(tftypes.String, "world"), + }, + ), + }, + ), + expected: NewDynamicValue( + NewTupleValueMust( + []attr.Type{ + BoolType{}, + StringType{}, + NumberType{}, + ObjectType{ + AttrTypes: map[string]attr.Type{ + "nested_attr1": StringType{}, + }, + }, + }, + []attr.Value{ + NewBoolNull(), + NewStringValue("hello"), + NewNumberValue(big.NewFloat(9.1)), + NewObjectValueMust( + map[string]attr.Type{ + "nested_attr1": StringType{}, + }, + map[string]attr.Value{ + "nested_attr1": NewStringValue("world"), + }, + ), + }, + ), + ), + }, + "tuple-with-dpt-null-to-dynamic": { + input: tftypes.NewValue( + tftypes.Tuple{ + ElementTypes: []tftypes.Type{ + tftypes.Bool, + tftypes.String, + tftypes.Number, + tftypes.DynamicPseudoType, + }, + }, []tftypes.Value{ + tftypes.NewValue(tftypes.Bool, nil), + tftypes.NewValue(tftypes.String, "hello"), + tftypes.NewValue(tftypes.Number, big.NewFloat(9.1)), + tftypes.NewValue(tftypes.DynamicPseudoType, nil), + }, + ), + expected: NewDynamicValue( + NewTupleValueMust( + []attr.Type{ + BoolType{}, + StringType{}, + NumberType{}, + DynamicType{}, + }, + []attr.Value{ + NewBoolNull(), + NewStringValue("hello"), + NewNumberValue(big.NewFloat(9.1)), + NewDynamicNull(), + }, + ), + ), + }, + "tuple-with-dpt-unknown-to-dynamic": { + input: tftypes.NewValue( + tftypes.Tuple{ + ElementTypes: []tftypes.Type{ + tftypes.Bool, + tftypes.String, + tftypes.Number, + tftypes.DynamicPseudoType, + }, + }, []tftypes.Value{ + tftypes.NewValue(tftypes.Bool, nil), + tftypes.NewValue(tftypes.String, "hello"), + tftypes.NewValue(tftypes.Number, big.NewFloat(9.1)), + tftypes.NewValue(tftypes.DynamicPseudoType, tftypes.UnknownValue), + }, + ), + expected: NewDynamicValue( + NewTupleValueMust( + []attr.Type{ + BoolType{}, + StringType{}, + NumberType{}, + DynamicType{}, + }, + []attr.Value{ + NewBoolNull(), + NewStringValue("hello"), + NewNumberValue(big.NewFloat(9.1)), + NewDynamicUnknown(), + }, + ), + ), + }, + } + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, gotErr := DynamicType{}.ValueFromTerraform(context.Background(), test.input) + if gotErr != nil { + if test.expectedErr == "" { + t.Errorf("Unexpected error: %s", gotErr.Error()) + return + } + if gotErr.Error() != test.expectedErr { + t.Errorf("Expected error to be %q, got %q", test.expectedErr, gotErr.Error()) + return + } + } + if gotErr == nil && test.expectedErr != "" { + t.Errorf("Expected error to be %q, got nil", test.expectedErr) + return + } + if diff := cmp.Diff(got, test.expected); diff != "" { + t.Errorf("Unexpected diff (-expected, +got): %s", diff) + } + if test.expected != nil && test.expected.IsNull() != test.input.IsNull() { + // It's possible for a known dynamic value to have an null underlying value + dynVal := test.expected.(DynamicValue) //nolint: forcetypeassert + if dynVal.IsUnderlyingValueNull() != test.input.IsNull() { + t.Errorf("Expected null-ness match: expected %t, got %t", test.expected.IsNull(), test.input.IsNull()) + } + } + if test.expected != nil && test.expected.IsUnknown() != !test.input.IsKnown() { + // It's possible for a known dynamic value to have an unknown underlying value + dynVal := test.expected.(DynamicValue) //nolint: forcetypeassert + if dynVal.IsUnderlyingValueUnknown() != !test.input.IsKnown() { + t.Errorf("Expected unknown-ness match: expected %t, got %t", test.expected.IsUnknown(), !test.input.IsKnown()) + } + } + }) + } +} diff --git a/types/basetypes/dynamic_value.go b/types/basetypes/dynamic_value.go new file mode 100644 index 000000000..07946e18c --- /dev/null +++ b/types/basetypes/dynamic_value.go @@ -0,0 +1,197 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package basetypes + +import ( + "context" + "errors" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +var ( + _ DynamicValuable = DynamicValue{} +) + +// DynamicValuable extends attr.Value for dynamic value types. Implement this interface +// to create a custom Dynamic value type. +type DynamicValuable interface { + attr.Value + + // ToDynamicValue should convert the value type to a DynamicValue. + ToDynamicValue(context.Context) (DynamicValue, diag.Diagnostics) +} + +// DynamicValuableWithSemanticEquals extends DynamicValuable with semantic equality logic. +type DynamicValuableWithSemanticEquals interface { + DynamicValuable + + // DynamicSemanticEquals should return true if the given value is + // semantically equal to the current value. This logic is used to prevent + // Terraform data consistency errors and resource drift where a value change + // may have inconsequential differences. + // + // Only known values are compared with this method as changing a value's + // state implicitly represents a different value. + DynamicSemanticEquals(context.Context, DynamicValuable) (bool, diag.Diagnostics) +} + +// NewDynamicValue creates a Dynamic with a known value. Access the value via the Dynamic +// type UnderlyingValue method. The concrete value type returned to Terraform from this value +// will be determined by the provided `(attr.Value).ToTerraformValue` function. +func NewDynamicValue(value attr.Value) DynamicValue { + if value == nil { + return NewDynamicNull() + } + + return DynamicValue{ + value: value, + state: attr.ValueStateKnown, + } +} + +// NewDynamicNull creates a Dynamic with a null value. The concrete value type returned to Terraform +// from this value will be tftypes.DynamicPseudoType. +func NewDynamicNull() DynamicValue { + return DynamicValue{ + state: attr.ValueStateNull, + } +} + +// NewDynamicUnknown creates a Dynamic with an unknown value. The concrete value type returned to Terraform +// from this value will be tftypes.DynamicPseudoType. +func NewDynamicUnknown() DynamicValue { + return DynamicValue{ + state: attr.ValueStateUnknown, + } +} + +// DynamicValue represents a dynamic value. Static types are always +// preferable over dynamic types in Terraform as practitioners will receive less +// helpful configuration assistance from validation error diagnostics and editor +// integrations. +type DynamicValue struct { + // value contains the known value, if not null or unknown. + value attr.Value + + // state represents whether the value is null, unknown, or known. The + // zero-value is null. + state attr.ValueState +} + +// Type returns DynamicType. +func (v DynamicValue) Type(ctx context.Context) attr.Type { + return DynamicType{} +} + +// ToTerraformValue returns the equivalent tftypes.Value for the DynamicValue. +func (v DynamicValue) ToTerraformValue(ctx context.Context) (tftypes.Value, error) { + switch v.state { + case attr.ValueStateKnown: + if v.value == nil { + return tftypes.NewValue(tftypes.DynamicPseudoType, tftypes.UnknownValue), + errors.New("invalid Dynamic state in ToTerraformValue: DynamicValue is known but the underlying value is unset") + } + + return v.value.ToTerraformValue(ctx) + case attr.ValueStateNull: + return tftypes.NewValue(tftypes.DynamicPseudoType, nil), nil + case attr.ValueStateUnknown: + return tftypes.NewValue(tftypes.DynamicPseudoType, tftypes.UnknownValue), nil + default: + panic(fmt.Sprintf("unhandled Dynamic state in ToTerraformValue: %s", v.state)) + } +} + +// Equal returns true if the given attr.Value is also a DynamicValue and contains an equal underlying value as defined by its Equal method. +func (v DynamicValue) Equal(o attr.Value) bool { + other, ok := o.(DynamicValue) + if !ok { + return false + } + + if v.state != other.state { + return false + } + + if v.state != attr.ValueStateKnown { + return true + } + + // Prevent panic and force inequality if either underlying value is nil + if v.value == nil || other.value == nil { + return false + } + + return v.value.Equal(other.value) +} + +// IsNull returns true if the DynamicValue represents a null value. +func (v DynamicValue) IsNull() bool { + return v.state == attr.ValueStateNull +} + +// IsUnknown returns true if the DynamicValue represents an unknown value. +func (v DynamicValue) IsUnknown() bool { + return v.state == attr.ValueStateUnknown +} + +// String returns a human-readable representation of the DynamicValue. The string returned here is not protected by any compatibility guarantees, +// and is intended for logging and error reporting. +func (v DynamicValue) String() string { + if v.IsUnknown() { + return attr.UnknownValueString + } + + if v.IsNull() { + return attr.NullValueString + } + + if v.value == nil { + return attr.UnsetValueString + } + + return v.value.String() +} + +// ToDynamicValue returns DynamicValue. +func (v DynamicValue) ToDynamicValue(ctx context.Context) (DynamicValue, diag.Diagnostics) { + return v, nil +} + +// UnderlyingValue returns the concrete underlying value in the DynamicValue. This will return `nil` +// if DynamicValue is null or unknown. +// +// A known DynamicValue can have an underlying value that is in null or unknown state in the +// scenario that the underlying value type has been refined by Terraform. +func (v DynamicValue) UnderlyingValue() attr.Value { + return v.value +} + +// IsUnderlyingValueNull is a helper method that return true only in the case where the underlying value has a +// known type but the value is null. This method will return false if the underlying type is not known. +// +// IsNull should be used to determine if the dynamic value does not have a known type and the value is null. +// +// An example of a known type with a null underlying value would be: +// +// types.DynamicValue(types.StringNull()) +func (v DynamicValue) IsUnderlyingValueNull() bool { + return v.value != nil && v.value.IsNull() +} + +// IsUnderlyingValueUnknown is a helper method that return true only in the case where the underlying value has a +// known type but the value is unknown. This method will return false if the underlying type is not known. +// +// IsUnknown should be used to determine if the dynamic value does not have a known type and the value is unknown. +// +// An example of a known type with an unknown underlying value would be: +// +// types.DynamicValue(types.StringUnknown()) +func (v DynamicValue) IsUnderlyingValueUnknown() bool { + return v.value != nil && v.value.IsUnknown() +} diff --git a/types/basetypes/dynamic_value_test.go b/types/basetypes/dynamic_value_test.go new file mode 100644 index 000000000..ba8512d17 --- /dev/null +++ b/types/basetypes/dynamic_value_test.go @@ -0,0 +1,790 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package basetypes + +import ( + "context" + "errors" + "math/big" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestNewDynamicValue(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + input attr.Value + expected DynamicValue + }{ + "nil": { + input: nil, + expected: NewDynamicNull(), + }, + "known": { + input: NewStringValue("hello world"), + expected: NewDynamicValue(NewStringValue("hello world")), + }, + // This represents when Terraform knows what the type will be, but the value is null. + "known-underlying-value-null": { + input: NewStringNull(), + expected: NewDynamicValue(NewStringNull()), + }, + // This represents when Terraform knows what the type will be, but doesn't yet know the value. + "known-underlying-value-unknown": { + input: NewStringUnknown(), + expected: NewDynamicValue(NewStringUnknown()), + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := NewDynamicValue(testCase.input) + + if diff := cmp.Diff(got, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestDynamicValueToTerraformValue(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + input DynamicValue + expected tftypes.Value + expectedError error + }{ + "known-primitive": { + input: NewDynamicValue(NewStringValue("test")), + expected: tftypes.NewValue(tftypes.String, "test"), + }, + "known-collection": { + input: NewDynamicValue( + NewListValueMust(NumberType{}, []attr.Value{ + NewNumberValue(big.NewFloat(1.234)), + NewNumberValue(big.NewFloat(100)), + NewNumberValue(big.NewFloat(222.1)), + }), + ), + expected: tftypes.NewValue( + tftypes.List{ + ElementType: tftypes.Number, + }, + []tftypes.Value{ + tftypes.NewValue(tftypes.Number, big.NewFloat(1.234)), + tftypes.NewValue(tftypes.Number, big.NewFloat(100)), + tftypes.NewValue(tftypes.Number, big.NewFloat(222.1)), + }, + ), + }, + "known-structural": { + input: NewDynamicValue( + NewObjectValueMust( + map[string]attr.Type{ + "string_val": StringType{}, + "map_val": MapType{ElemType: NumberType{}}, + }, + map[string]attr.Value{ + "string_val": NewStringValue("hello world"), + "map_val": NewMapValueMust( + NumberType{}, + map[string]attr.Value{ + "num1": NewNumberValue(big.NewFloat(1.234)), + "num2": NewNumberValue(big.NewFloat(100)), + }, + ), + }, + ), + ), + expected: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "string_val": tftypes.String, + "map_val": tftypes.Map{ + ElementType: tftypes.Number, + }, + }, + }, + map[string]tftypes.Value{ + "string_val": tftypes.NewValue(tftypes.String, "hello world"), + "map_val": tftypes.NewValue( + tftypes.Map{ElementType: tftypes.Number}, + map[string]tftypes.Value{ + "num1": tftypes.NewValue(tftypes.Number, big.NewFloat(1.234)), + "num2": tftypes.NewValue(tftypes.Number, big.NewFloat(100)), + }, + ), + }, + ), + }, + "known-nil-underlying-value": { + input: DynamicValue{ + value: nil, // Should not panic + state: attr.ValueStateKnown, + }, + expected: tftypes.NewValue(tftypes.DynamicPseudoType, tftypes.UnknownValue), + expectedError: errors.New("invalid Dynamic state in ToTerraformValue: DynamicValue is known but the underlying value is unset"), + }, + "null": { + input: NewDynamicNull(), + expected: tftypes.NewValue(tftypes.DynamicPseudoType, nil), + }, + "unknown": { + input: NewDynamicUnknown(), + expected: tftypes.NewValue(tftypes.DynamicPseudoType, tftypes.UnknownValue), + }, + // For dynamic values, it's possible the underlying type is known but the underlying value itself is null. In this + // situation, the type information must be preserved when returned back to Terraform. + "null-value-known-type": { + input: NewDynamicValue(NewBoolNull()), + expected: tftypes.NewValue(tftypes.Bool, nil), + }, + // For dynamic values, it's possible the underlying type is known but the underlying value itself is unknown. In this + // situation, the type information must be preserved when returned back to Terraform. + "unknown-value-known-type": { + input: NewDynamicValue(NewListUnknown(StringType{})), + expected: tftypes.NewValue(tftypes.List{ElementType: tftypes.String}, tftypes.UnknownValue), + }, + } + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + ctx := context.Background() + + got, err := test.input.ToTerraformValue(ctx) + if err != nil { + if test.expectedError == nil { + t.Fatalf("expected no error, got: %s", err) + } + + if !strings.Contains(err.Error(), test.expectedError.Error()) { + t.Fatalf("expected error %q, got: %s", test.expectedError, err) + } + } + + if diff := cmp.Diff(test.expected, got); diff != "" { + t.Errorf("Unexpected diff (+wanted, -got): %s", diff) + } + }) + } +} + +func TestDynamicValueEqual(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + input DynamicValue + candidate attr.Value + expectation bool + }{ + "known-known-same-primitive": { + input: NewDynamicValue(NewStringValue("hello")), + candidate: NewDynamicValue(NewStringValue("hello")), + expectation: true, + }, + "known-known-diff-primitive": { + input: NewDynamicValue(NewStringValue("hello")), + candidate: NewDynamicValue(NewStringValue("goodbye")), + expectation: false, + }, + "known-known-same-collection": { + input: NewDynamicValue( + NewSetValueMust(NumberType{}, []attr.Value{ + NewNumberValue(big.NewFloat(1.234)), + NewNumberValue(big.NewFloat(100)), + NewNumberValue(big.NewFloat(222.1)), + }), + ), + candidate: NewDynamicValue( + NewSetValueMust(NumberType{}, []attr.Value{ + NewNumberValue(big.NewFloat(1.234)), + NewNumberValue(big.NewFloat(100)), + NewNumberValue(big.NewFloat(222.1)), + }), + ), + expectation: true, + }, + "known-known-diff-collection": { + input: NewDynamicValue( + NewSetValueMust(NumberType{}, []attr.Value{ + NewNumberValue(big.NewFloat(1.234)), + NewNumberValue(big.NewFloat(100)), + NewNumberValue(big.NewFloat(222.1)), + }), + ), + candidate: NewDynamicValue( + NewSetValueMust(NumberType{}, []attr.Value{ + NewNumberValue(big.NewFloat(1.234)), + NewNumberValue(big.NewFloat(23)), + NewNumberValue(big.NewFloat(222.1)), + }), + ), + expectation: false, + }, + "known-known-same-structural": { + input: NewDynamicValue( + NewObjectValueMust( + map[string]attr.Type{ + "string_val": StringType{}, + "map_val": MapType{ElemType: NumberType{}}, + }, + map[string]attr.Value{ + "string_val": NewStringValue("hello world"), + "map_val": NewMapValueMust( + NumberType{}, + map[string]attr.Value{ + "num1": NewNumberValue(big.NewFloat(1.234)), + "num2": NewNumberValue(big.NewFloat(100)), + }, + ), + }, + ), + ), + candidate: NewDynamicValue( + NewObjectValueMust( + map[string]attr.Type{ + "string_val": StringType{}, + "map_val": MapType{ElemType: NumberType{}}, + }, + map[string]attr.Value{ + "string_val": NewStringValue("hello world"), + "map_val": NewMapValueMust( + NumberType{}, + map[string]attr.Value{ + "num1": NewNumberValue(big.NewFloat(1.234)), + "num2": NewNumberValue(big.NewFloat(100)), + }, + ), + }, + ), + ), + expectation: true, + }, + "known-known-diff-structural": { + input: NewDynamicValue( + NewObjectValueMust( + map[string]attr.Type{ + "string_val": StringType{}, + "map_val": MapType{ElemType: NumberType{}}, + }, + map[string]attr.Value{ + "string_val": NewStringValue("hello world"), + "map_val": NewMapValueMust( + NumberType{}, + map[string]attr.Value{ + "num1": NewNumberValue(big.NewFloat(1.234)), + "num2": NewNumberValue(big.NewFloat(100)), + }, + ), + }, + ), + ), + candidate: NewDynamicValue( + NewObjectValueMust( + map[string]attr.Type{ + "string_val": StringType{}, + "map_val": MapType{ElemType: NumberType{}}, + }, + map[string]attr.Value{ + "string_val": NewStringValue("goodbye!"), + "map_val": NewMapValueMust( + NumberType{}, + map[string]attr.Value{ + "num1": NewNumberValue(big.NewFloat(1.234)), + "num2": NewNumberValue(big.NewFloat(100)), + }, + ), + }, + ), + ), + expectation: false, + }, + "known-unknown": { + input: NewDynamicValue(NewStringValue("hello")), + candidate: NewDynamicUnknown(), + expectation: false, + }, + "known-null": { + input: NewDynamicValue(NewStringValue("hello")), + candidate: NewDynamicNull(), + expectation: false, + }, + "unknown-value": { + input: NewDynamicUnknown(), + candidate: NewDynamicValue(NewStringValue("hello")), + expectation: false, + }, + "unknown-unknown": { + input: NewDynamicUnknown(), + candidate: NewDynamicUnknown(), + expectation: true, + }, + "unknown-null": { + input: NewDynamicUnknown(), + candidate: NewDynamicNull(), + expectation: false, + }, + "null-known": { + input: NewDynamicNull(), + candidate: NewDynamicValue(NewStringValue("hello")), + expectation: false, + }, + "null-unknown": { + input: NewDynamicNull(), + candidate: NewDynamicUnknown(), + expectation: false, + }, + "null-null": { + input: NewDynamicNull(), + candidate: NewDynamicNull(), + expectation: true, + }, + "known-known-no-dynamic-wrapper": { + input: NewDynamicValue(NewStringValue("hello")), + candidate: NewStringValue("hello"), + expectation: false, + }, + "unknown-unknown-no-dynamic-wrapper": { + input: NewDynamicUnknown(), + candidate: NewStringUnknown(), + expectation: false, + }, + "null-null-no-dynamic-wrapper": { + input: NewDynamicNull(), + candidate: NewStringNull(), + expectation: false, + }, + "known-underlying-value-unset-input": { + input: DynamicValue{ + value: nil, // Should not panic + state: attr.ValueStateKnown, + }, + candidate: NewDynamicNull(), + expectation: false, + }, + "known-underlying-value-unset-candidate": { + input: NewDynamicNull(), + candidate: DynamicValue{ + value: nil, // Should not panic + state: attr.ValueStateKnown, + }, + expectation: false, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := test.input.Equal(test.candidate) + if !cmp.Equal(got, test.expectation) { + t.Errorf("Expected %v, got %v", test.expectation, got) + } + }) + } +} + +func TestDynamicValueString(t *testing.T) { + t.Parallel() + + type testCase struct { + input DynamicValue + expectation string + } + tests := map[string]testCase{ + "known-primitive": { + input: NewDynamicValue(NewStringValue("hello world")), + expectation: `"hello world"`, + }, + "known-collection": { + input: NewDynamicValue(NewListValueMust( + StringType{}, + []attr.Value{ + NewStringValue("hello"), + NewStringValue("world"), + }, + )), + expectation: `["hello","world"]`, + }, + "known-tuple": { + input: NewDynamicValue(NewTupleValueMust( + []attr.Type{ + StringType{}, + StringType{}, + }, + []attr.Value{ + NewStringValue("hello"), + NewStringValue("world"), + }, + )), + expectation: `["hello","world"]`, + }, + "known-structural": { + input: NewDynamicValue(NewObjectValueMust( + map[string]attr.Type{ + "alpha": StringType{}, + "beta": StringType{}, + }, + map[string]attr.Value{ + "alpha": NewStringValue("hello"), + "beta": NewStringValue("world"), + }, + )), + expectation: `{"alpha":"hello","beta":"world"}`, + }, + "known-nil-underlying-value": { + input: DynamicValue{ + value: nil, // Should not panic + state: attr.ValueStateKnown, + }, + expectation: "", + }, + "unknown": { + input: NewDynamicUnknown(), + expectation: "", + }, + "null": { + input: NewDynamicNull(), + expectation: "", + }, + "zero-value": { + input: DynamicValue{}, + expectation: "", + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := test.input.String() + if !cmp.Equal(got, test.expectation) { + t.Errorf("Expected %q, got %q", test.expectation, got) + } + }) + } +} + +func TestDynamicValueIsUnderlyingValueNull(t *testing.T) { + t.Parallel() + + type testCase struct { + input DynamicValue + expectation bool + } + tests := map[string]testCase{ + "known-primitive": { + input: NewDynamicValue(NewStringValue("hello world")), + expectation: false, + }, + "known-primitive-underlying-value-null": { + input: NewDynamicValue(NewStringNull()), + expectation: true, + }, + "known-primitive-underlying-value-unknown": { + input: NewDynamicValue(NewStringUnknown()), + expectation: false, + }, + "known-collection": { + input: NewDynamicValue(NewListValueMust( + StringType{}, + []attr.Value{ + NewStringValue("hello"), + NewStringValue("world"), + }, + )), + expectation: false, + }, + "known-collection-underlying-value-null": { + input: NewDynamicValue(NewListNull( + StringType{}, + )), + expectation: true, + }, + "known-collection-underlying-value-unknown": { + input: NewDynamicValue(NewListUnknown( + StringType{}, + )), + expectation: false, + }, + "known-tuple": { + input: NewDynamicValue(NewTupleValueMust( + []attr.Type{ + StringType{}, + StringType{}, + }, + []attr.Value{ + NewStringValue("hello"), + NewStringValue("world"), + }, + )), + expectation: false, + }, + "known-tuple-underlying-value-null": { + input: NewDynamicValue(NewTupleNull( + []attr.Type{ + StringType{}, + StringType{}, + }, + )), + expectation: true, + }, + "known-tuple-underlying-value-unknown": { + input: NewDynamicValue(NewTupleUnknown( + []attr.Type{ + StringType{}, + StringType{}, + }, + )), + expectation: false, + }, + "known-structural": { + input: NewDynamicValue(NewObjectValueMust( + map[string]attr.Type{ + "alpha": StringType{}, + "beta": StringType{}, + }, + map[string]attr.Value{ + "alpha": NewStringValue("hello"), + "beta": NewStringValue("world"), + }, + )), + expectation: false, + }, + "known-structural-underlying-value-null": { + input: NewDynamicValue(NewObjectNull( + map[string]attr.Type{ + "alpha": StringType{}, + "beta": StringType{}, + }, + )), + expectation: true, + }, + "known-structural-underlying-value-unknown": { + input: NewDynamicValue(NewObjectUnknown( + map[string]attr.Type{ + "alpha": StringType{}, + "beta": StringType{}, + }, + )), + expectation: false, + }, + "known-nil-underlying-value": { + input: DynamicValue{ + value: nil, // Should not panic + state: attr.ValueStateKnown, + }, + expectation: false, + }, + "null-nil-underlying-value": { + input: DynamicValue{ + value: nil, // Should not panic + state: attr.ValueStateNull, + }, + expectation: false, + }, + "unknown-nil-underlying-value": { + input: DynamicValue{ + value: nil, // Should not panic + state: attr.ValueStateUnknown, + }, + expectation: false, + }, + "unknown": { + input: NewDynamicUnknown(), + // There is no underlying value, so it's not null + expectation: false, + }, + "null": { + input: NewDynamicNull(), + // There is no underlying value, so it's not null + expectation: false, + }, + "zero-value": { + input: DynamicValue{}, + // There is no underlying value, so it's not null + expectation: false, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := test.input.IsUnderlyingValueNull() + if !cmp.Equal(got, test.expectation) { + t.Errorf("Expected %t, got %t", test.expectation, got) + } + }) + } +} + +func TestDynamicValueIsUnderlyingValueUnknown(t *testing.T) { + t.Parallel() + + type testCase struct { + input DynamicValue + expectation bool + } + tests := map[string]testCase{ + "known-primitive": { + input: NewDynamicValue(NewStringValue("hello world")), + expectation: false, + }, + "known-primitive-underlying-value-null": { + input: NewDynamicValue(NewStringNull()), + expectation: false, + }, + "known-primitive-underlying-value-unknown": { + input: NewDynamicValue(NewStringUnknown()), + expectation: true, + }, + "known-collection": { + input: NewDynamicValue(NewListValueMust( + StringType{}, + []attr.Value{ + NewStringValue("hello"), + NewStringValue("world"), + }, + )), + expectation: false, + }, + "known-collection-underlying-value-null": { + input: NewDynamicValue(NewListNull( + StringType{}, + )), + expectation: false, + }, + "known-collection-underlying-value-unknown": { + input: NewDynamicValue(NewListUnknown( + StringType{}, + )), + expectation: true, + }, + "known-tuple": { + input: NewDynamicValue(NewTupleValueMust( + []attr.Type{ + StringType{}, + StringType{}, + }, + []attr.Value{ + NewStringValue("hello"), + NewStringValue("world"), + }, + )), + expectation: false, + }, + "known-tuple-underlying-value-null": { + input: NewDynamicValue(NewTupleNull( + []attr.Type{ + StringType{}, + StringType{}, + }, + )), + expectation: false, + }, + "known-tuple-underlying-value-unknown": { + input: NewDynamicValue(NewTupleUnknown( + []attr.Type{ + StringType{}, + StringType{}, + }, + )), + expectation: true, + }, + "known-structural": { + input: NewDynamicValue(NewObjectValueMust( + map[string]attr.Type{ + "alpha": StringType{}, + "beta": StringType{}, + }, + map[string]attr.Value{ + "alpha": NewStringValue("hello"), + "beta": NewStringValue("world"), + }, + )), + expectation: false, + }, + "known-structural-underlying-value-null": { + input: NewDynamicValue(NewObjectNull( + map[string]attr.Type{ + "alpha": StringType{}, + "beta": StringType{}, + }, + )), + expectation: false, + }, + "known-structural-underlying-value-unknown": { + input: NewDynamicValue(NewObjectUnknown( + map[string]attr.Type{ + "alpha": StringType{}, + "beta": StringType{}, + }, + )), + expectation: true, + }, + "known-nil-underlying-value": { + input: DynamicValue{ + value: nil, // Should not panic + state: attr.ValueStateKnown, + }, + expectation: false, + }, + "null-nil-underlying-value": { + input: DynamicValue{ + value: nil, // Should not panic + state: attr.ValueStateNull, + }, + expectation: false, + }, + "unknown-nil-underlying-value": { + input: DynamicValue{ + value: nil, // Should not panic + state: attr.ValueStateUnknown, + }, + expectation: false, + }, + "unknown": { + input: NewDynamicUnknown(), + // There is no underlying value, so it's not null + expectation: false, + }, + "null": { + input: NewDynamicNull(), + // There is no underlying value, so it's not null + expectation: false, + }, + "zero-value": { + input: DynamicValue{}, + // There is no underlying value, so it's not null + expectation: false, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + + got := test.input.IsUnderlyingValueUnknown() + if !cmp.Equal(got, test.expectation) { + t.Errorf("Expected %t, got %t", test.expectation, got) + } + }) + } +} diff --git a/types/basetypes/list_type.go b/types/basetypes/list_type.go index 146f3a4fb..b97378d40 100644 --- a/types/basetypes/list_type.go +++ b/types/basetypes/list_type.go @@ -65,6 +65,20 @@ func (l ListType) ValueFromTerraform(ctx context.Context, in tftypes.Value) (att if in.Type() == nil { return NewListNull(l.ElementType()), nil } + + // MAINTAINER NOTE: + // ListType does not support DynamicType as an element type. It is not explicitly prevented from being created with the + // Framework type system, but the Framework-supported ListAttribute, ListNestedAttribute, and ListNestedBlock all prevent DynamicType + // from being used as an element type. An attempt to use DynamicType as the element type will eventually lead you to an error on this line :) + // + // In the future, if we ever need to support a list of dynamic element types, this type equality check will need to be modified to allow + // dynamic types to not return an error, as the tftypes.Value coming in (if known) will be a concrete value, for example: + // + // - l.TerraformType(ctx): tftypes.List[tftypes.DynamicPseudoType] + // - in.Type(): tftypes.List[tftypes.String] + // + // The `ValueFromTerraform` function for a dynamic type will be able create the correct concrete dynamic value with this modification in place. + // if !in.Type().Equal(l.TerraformType(ctx)) { return nil, fmt.Errorf("can't use %s as value of List with ElementType %T, can only use %s values", in.String(), l.ElementType(), l.ElementType().TerraformType(ctx).String()) } diff --git a/types/basetypes/list_value.go b/types/basetypes/list_value.go index b37145c66..f89cbb73f 100644 --- a/types/basetypes/list_value.go +++ b/types/basetypes/list_value.go @@ -209,6 +209,19 @@ func (l ListValue) ToTerraformValue(ctx context.Context) (tftypes.Value, error) switch l.state { case attr.ValueStateKnown: + // MAINTAINER NOTE: + // ListValue does not support DynamicType as an element type. It is not explicitly prevented from being created with the + // Framework type system, but the Framework-supported ListAttribute, ListNestedAttribute, and ListNestedBlock all prevent DynamicType + // from being used as an element type. + // + // In the future, if we ever need to support a list of dynamic element types, this tftypes.List creation logic will need to be modified to ensure + // that known values contain the exact same concrete element type, specifically with unknown and null values. Dynamic values will return the correct concrete + // element type for known values from `elem.ToTerraformValue`, but unknown and null values will be tftypes.DynamicPseudoType, causing an error due to multiple element + // types in a tftypes.List. + // + // Unknown and null element types of tftypes.DynamicPseudoType must be recreated as the concrete element type unknown/null value. This can be done by checking `l.elements` + // for a single concrete type (i.e. not tftypes.DynamicPseudoType), and using that concrete type to create unknown and null dynamic values later. + // vals := make([]tftypes.Value, 0, len(l.elements)) for _, elem := range l.elements { diff --git a/types/basetypes/list_value_test.go b/types/basetypes/list_value_test.go index 1c40cfef0..a3db64168 100644 --- a/types/basetypes/list_value_test.go +++ b/types/basetypes/list_value_test.go @@ -302,6 +302,44 @@ func TestListValueToTerraformValue(t *testing.T) { input: NewListNull(StringType{}), expectation: tftypes.NewValue(tftypes.List{ElementType: tftypes.String}, nil), }, + // In the scenario where Terraform has refined a dynamic type to a list but the element type is not known, it's possible + // to receive a list with a dynamic element type. + // + // An example configuration that demonstrates this scenario, where "dynamic_attr" is a schema.DynamicAttribute: + // + // resource "examplecloud_thing" "this" { + // dynamic_attr = tolist([]) + // } + // + // And the resulting state value: + // + // "dynamic_attr": { + // "value": [], + // "type": [ + // "list", + // "dynamic" + // ] + // } + // + "known-empty-dynamic-element-type": { + input: NewListValueMust( + DynamicType{}, + []attr.Value{}, + ), + expectation: tftypes.NewValue(tftypes.List{ElementType: tftypes.DynamicPseudoType}, []tftypes.Value{}), + }, + "unknown-dynamic-element-type": { + input: NewListUnknown( + DynamicType{}, + ), + expectation: tftypes.NewValue(tftypes.List{ElementType: tftypes.DynamicPseudoType}, tftypes.UnknownValue), + }, + "null-dynamic-element-type": { + input: NewListNull( + DynamicType{}, + ), + expectation: tftypes.NewValue(tftypes.List{ElementType: tftypes.DynamicPseudoType}, nil), + }, } for name, test := range tests { name, test := name, test diff --git a/types/basetypes/map_type.go b/types/basetypes/map_type.go index 0c356f67f..03d2f7ce2 100644 --- a/types/basetypes/map_type.go +++ b/types/basetypes/map_type.go @@ -68,6 +68,20 @@ func (m MapType) ValueFromTerraform(ctx context.Context, in tftypes.Value) (attr if !in.Type().Is(tftypes.Map{}) { return nil, fmt.Errorf("can't use %s as value of MapValue, can only use tftypes.Map values", in.String()) } + + // MAINTAINER NOTE: + // MapType does not support DynamicType as an element type. It is not explicitly prevented from being created with the + // Framework type system, but the Framework-supported MapAttribute and MapNestedAttribute prevent DynamicType + // from being used as an element type. An attempt to use DynamicType as the element type will eventually lead you to an error on this line :) + // + // In the future, if we ever need to support a map of dynamic element types, this type equality check will need to be modified to allow + // dynamic types to not return an error, as the tftypes.Value coming in (if known) will be a concrete value, for example: + // + // - m.TerraformType(ctx): tftypes.Map[tftypes.DynamicPseudoType] + // - in.Type(): tftypes.Map[tftypes.String] + // + // The `ValueFromTerraform` function for a dynamic type will be able create the correct concrete dynamic value with this modification in place. + // if !in.Type().Equal(tftypes.Map{ElementType: m.ElementType().TerraformType(ctx)}) { return nil, fmt.Errorf("can't use %s as value of Map with ElementType %T, can only use %s values", in.String(), m.ElementType(), m.ElementType().TerraformType(ctx).String()) } diff --git a/types/basetypes/map_value.go b/types/basetypes/map_value.go index 9fcbfbdd3..56a64361c 100644 --- a/types/basetypes/map_value.go +++ b/types/basetypes/map_value.go @@ -216,6 +216,19 @@ func (m MapValue) ToTerraformValue(ctx context.Context) (tftypes.Value, error) { switch m.state { case attr.ValueStateKnown: + // MAINTAINER NOTE: + // MapValue does not support DynamicType as an element type. It is not explicitly prevented from being created with the + // Framework type system, but the Framework-supported MapAttribute and MapNestedAttribute prevent DynamicType + // from being used as an element type. + // + // In the future, if we ever need to support a map of dynamic element types, this tftypes.Map creation logic will need to be modified to ensure + // that known values contain the exact same concrete element type, specifically with unknown and null values. Dynamic values will return the correct concrete + // element type for known values from `elem.ToTerraformValue`, but unknown and null values will be tftypes.DynamicPseudoType, causing an error due to multiple element + // types in a tftypes.Map. + // + // Unknown and null element types of tftypes.DynamicPseudoType must be recreated as the concrete element type unknown/null value. This can be done by checking `m.elements` + // for a single concrete type (i.e. not tftypes.DynamicPseudoType), and using that concrete type to create unknown and null dynamic values later. + // vals := make(map[string]tftypes.Value, len(m.elements)) for key, elem := range m.elements { diff --git a/types/basetypes/map_value_test.go b/types/basetypes/map_value_test.go index 3ebc6c04e..8bfd3753b 100644 --- a/types/basetypes/map_value_test.go +++ b/types/basetypes/map_value_test.go @@ -305,6 +305,44 @@ func TestMapValueToTerraformValue(t *testing.T) { input: NewMapNull(StringType{}), expectation: tftypes.NewValue(tftypes.Map{ElementType: tftypes.String}, nil), }, + // In the scenario where Terraform has refined a dynamic type to a map but the element type is not known, it's possible + // to receive a map with a dynamic element type. + // + // An example configuration that demonstrates this scenario, where "dynamic_attr" is a schema.DynamicAttribute: + // + // resource "examplecloud_thing" "this" { + // dynamic_attr = tomap({}) + // } + // + // And the resulting state value: + // + // "dynamic_attr": { + // "value": {}, + // "type": [ + // "map", + // "dynamic" + // ] + // } + // + "known-empty-dynamic-element-type": { + input: NewMapValueMust( + DynamicType{}, + map[string]attr.Value{}, + ), + expectation: tftypes.NewValue(tftypes.Map{ElementType: tftypes.DynamicPseudoType}, map[string]tftypes.Value{}), + }, + "unknown-dynamic-element-type": { + input: NewMapUnknown( + DynamicType{}, + ), + expectation: tftypes.NewValue(tftypes.Map{ElementType: tftypes.DynamicPseudoType}, tftypes.UnknownValue), + }, + "null-dynamic-element-type": { + input: NewMapNull( + DynamicType{}, + ), + expectation: tftypes.NewValue(tftypes.Map{ElementType: tftypes.DynamicPseudoType}, nil), + }, } for name, test := range tests { name, test := name, test diff --git a/types/basetypes/object_type_test.go b/types/basetypes/object_type_test.go index e5c85be44..59d1ea838 100644 --- a/types/basetypes/object_type_test.go +++ b/types/basetypes/object_type_test.go @@ -96,6 +96,38 @@ func TestObjectTypeValueFromTerraform(t *testing.T) { }, ), }, + "basic-object-dynamic-types": { + receiver: ObjectType{ + AttrTypes: map[string]attr.Type{ + "a": DynamicType{}, + "b": DynamicType{}, + "c": DynamicType{}, + }, + }, + input: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "a": tftypes.DynamicPseudoType, + "b": tftypes.DynamicPseudoType, + "c": tftypes.DynamicPseudoType, + }, + }, map[string]tftypes.Value{ + "a": tftypes.NewValue(tftypes.String, "red"), + "b": tftypes.NewValue(tftypes.Bool, true), + "c": tftypes.NewValue(tftypes.Number, 123), + }), + expected: NewObjectValueMust( + map[string]attr.Type{ + "a": DynamicType{}, + "b": DynamicType{}, + "c": DynamicType{}, + }, + map[string]attr.Value{ + "a": NewDynamicValue(NewStringValue("red")), + "b": NewDynamicValue(NewBoolValue(true)), + "c": NewDynamicValue(NewNumberValue(big.NewFloat(123))), + }, + ), + }, "extra-attribute": { receiver: ObjectType{ AttrTypes: map[string]attr.Type{ diff --git a/types/basetypes/object_value_test.go b/types/basetypes/object_value_test.go index 0801081d7..73440f29c 100644 --- a/types/basetypes/object_value_test.go +++ b/types/basetypes/object_value_test.go @@ -730,6 +730,7 @@ func TestObjectValueToTerraformValue(t *testing.T) { }, }, "f": SetType{ElemType: StringType{}}, + "g": DynamicType{}, }, map[string]attr.Value{ "a": NewListValueMust( @@ -757,6 +758,7 @@ func TestObjectValueToTerraformValue(t *testing.T) { NewStringValue("world"), }, ), + "g": NewDynamicValue(NewStringValue("dynamic-woohoo")), }, ), expected: tftypes.NewValue(tftypes.Object{ @@ -767,6 +769,7 @@ func TestObjectValueToTerraformValue(t *testing.T) { "d": tftypes.Number, "e": tftypes.Object{AttributeTypes: map[string]tftypes.Type{"name": tftypes.String}}, "f": tftypes.Set{ElementType: tftypes.String}, + "g": tftypes.DynamicPseudoType, }, }, map[string]tftypes.Value{ "a": tftypes.NewValue(tftypes.List{ElementType: tftypes.String}, []tftypes.Value{ @@ -787,6 +790,7 @@ func TestObjectValueToTerraformValue(t *testing.T) { tftypes.NewValue(tftypes.String, "hello"), tftypes.NewValue(tftypes.String, "world"), }), + "g": tftypes.NewValue(tftypes.String, "dynamic-woohoo"), }), }, "unknown": { @@ -802,6 +806,7 @@ func TestObjectValueToTerraformValue(t *testing.T) { }, }, "f": SetType{ElemType: StringType{}}, + "g": DynamicType{}, }, ), expected: tftypes.NewValue(tftypes.Object{ @@ -816,6 +821,7 @@ func TestObjectValueToTerraformValue(t *testing.T) { }, }, "f": tftypes.Set{ElementType: tftypes.String}, + "g": tftypes.DynamicPseudoType, }, }, tftypes.UnknownValue), }, @@ -832,6 +838,7 @@ func TestObjectValueToTerraformValue(t *testing.T) { }, }, "f": SetType{ElemType: StringType{}}, + "g": DynamicType{}, }, ), expected: tftypes.NewValue(tftypes.Object{ @@ -846,6 +853,7 @@ func TestObjectValueToTerraformValue(t *testing.T) { }, }, "f": tftypes.Set{ElementType: tftypes.String}, + "g": tftypes.DynamicPseudoType, }, }, nil), }, @@ -862,6 +870,8 @@ func TestObjectValueToTerraformValue(t *testing.T) { }, }, "f": SetType{ElemType: StringType{}}, + "g": DynamicType{}, + "h": DynamicType{}, }, map[string]attr.Value{ "a": NewListValueMust( @@ -889,6 +899,8 @@ func TestObjectValueToTerraformValue(t *testing.T) { NewStringValue("world"), }, ), + "g": NewDynamicValue(NewStringValue("dynamic-woohoo")), + "h": NewDynamicUnknown(), }, ), expected: tftypes.NewValue(tftypes.Object{ @@ -903,6 +915,8 @@ func TestObjectValueToTerraformValue(t *testing.T) { }, }, "f": tftypes.Set{ElementType: tftypes.String}, + "g": tftypes.DynamicPseudoType, + "h": tftypes.DynamicPseudoType, }, }, map[string]tftypes.Value{ "a": tftypes.NewValue(tftypes.List{ElementType: tftypes.String}, []tftypes.Value{ @@ -923,6 +937,8 @@ func TestObjectValueToTerraformValue(t *testing.T) { tftypes.NewValue(tftypes.String, "hello"), tftypes.NewValue(tftypes.String, "world"), }), + "g": tftypes.NewValue(tftypes.String, "dynamic-woohoo"), + "h": tftypes.NewValue(tftypes.DynamicPseudoType, tftypes.UnknownValue), }), }, "partial-null": { @@ -938,6 +954,8 @@ func TestObjectValueToTerraformValue(t *testing.T) { }, }, "f": SetType{ElemType: StringType{}}, + "g": DynamicType{}, + "h": DynamicType{}, }, map[string]attr.Value{ "a": NewListValueMust( @@ -965,6 +983,8 @@ func TestObjectValueToTerraformValue(t *testing.T) { NewStringValue("world"), }, ), + "g": NewDynamicValue(NewStringValue("dynamic-woohoo")), + "h": NewDynamicNull(), }, ), expected: tftypes.NewValue(tftypes.Object{ @@ -979,6 +999,8 @@ func TestObjectValueToTerraformValue(t *testing.T) { }, }, "f": tftypes.Set{ElementType: tftypes.String}, + "g": tftypes.DynamicPseudoType, + "h": tftypes.DynamicPseudoType, }, }, map[string]tftypes.Value{ "a": tftypes.NewValue(tftypes.List{ElementType: tftypes.String}, []tftypes.Value{ @@ -999,6 +1021,8 @@ func TestObjectValueToTerraformValue(t *testing.T) { tftypes.NewValue(tftypes.String, "hello"), tftypes.NewValue(tftypes.String, "world"), }), + "g": tftypes.NewValue(tftypes.String, "dynamic-woohoo"), + "h": tftypes.NewValue(tftypes.DynamicPseudoType, nil), }), }, "deep-partial-unknown": { @@ -1014,6 +1038,7 @@ func TestObjectValueToTerraformValue(t *testing.T) { }, }, "f": SetType{ElemType: StringType{}}, + "g": DynamicType{}, }, map[string]attr.Value{ "a": NewListValueMust( @@ -1041,6 +1066,14 @@ func TestObjectValueToTerraformValue(t *testing.T) { NewStringValue("world"), }, ), + "g": NewDynamicValue(NewObjectValueMust( + map[string]attr.Type{ + "name": DynamicType{}, + }, + map[string]attr.Value{ + "name": NewDynamicUnknown(), + }, + )), }, ), expected: tftypes.NewValue(tftypes.Object{ @@ -1055,6 +1088,7 @@ func TestObjectValueToTerraformValue(t *testing.T) { }, }, "f": tftypes.Set{ElementType: tftypes.String}, + "g": tftypes.DynamicPseudoType, }, }, map[string]tftypes.Value{ "a": tftypes.NewValue(tftypes.List{ElementType: tftypes.String}, []tftypes.Value{ @@ -1075,6 +1109,13 @@ func TestObjectValueToTerraformValue(t *testing.T) { tftypes.NewValue(tftypes.String, "hello"), tftypes.NewValue(tftypes.String, "world"), }), + "g": tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "name": tftypes.DynamicPseudoType, + }, + }, map[string]tftypes.Value{ + "name": tftypes.NewValue(tftypes.DynamicPseudoType, tftypes.UnknownValue), + }), }), }, "deep-partial-null": { @@ -1090,6 +1131,7 @@ func TestObjectValueToTerraformValue(t *testing.T) { }, }, "f": SetType{ElemType: StringType{}}, + "g": DynamicType{}, }, map[string]attr.Value{ "a": NewListValueMust( @@ -1117,6 +1159,14 @@ func TestObjectValueToTerraformValue(t *testing.T) { NewStringValue("world"), }, ), + "g": NewDynamicValue(NewObjectValueMust( + map[string]attr.Type{ + "name": DynamicType{}, + }, + map[string]attr.Value{ + "name": NewDynamicNull(), + }, + )), }, ), expected: tftypes.NewValue(tftypes.Object{ @@ -1131,6 +1181,7 @@ func TestObjectValueToTerraformValue(t *testing.T) { }, }, "f": tftypes.Set{ElementType: tftypes.String}, + "g": tftypes.DynamicPseudoType, }, }, map[string]tftypes.Value{ "a": tftypes.NewValue(tftypes.List{ElementType: tftypes.String}, []tftypes.Value{ @@ -1151,6 +1202,13 @@ func TestObjectValueToTerraformValue(t *testing.T) { tftypes.NewValue(tftypes.String, "hello"), tftypes.NewValue(tftypes.String, "world"), }), + "g": tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "name": tftypes.DynamicPseudoType, + }, + }, map[string]tftypes.Value{ + "name": tftypes.NewValue(tftypes.DynamicPseudoType, nil), + }), }), }, } diff --git a/types/basetypes/set_type.go b/types/basetypes/set_type.go index 1f89957f4..9542b94ac 100644 --- a/types/basetypes/set_type.go +++ b/types/basetypes/set_type.go @@ -68,6 +68,20 @@ func (st SetType) ValueFromTerraform(ctx context.Context, in tftypes.Value) (att if in.Type() == nil { return NewSetNull(st.ElementType()), nil } + + // MAINTAINER NOTE: + // SetType does not support DynamicType as an element type. It is not explicitly prevented from being created with the + // Framework type system, but the Framework-supported SetAttribute, SetNestedAttribute, and SetNestedBlock all prevent DynamicType + // from being used as an element type. An attempt to use DynamicType as the element type will eventually lead you to an error on this line :) + // + // In the future, if we ever need to support a set of dynamic element types, this type equality check will need to be modified to allow + // dynamic types to not return an error, as the tftypes.Value coming in (if known) will be a concrete value, for example: + // + // - st.TerraformType(ctx): tftypes.Set[tftypes.DynamicPseudoType] + // - in.Type(): tftypes.Set[tftypes.String] + // + // The `ValueFromTerraform` function for a dynamic type will be able create the correct concrete dynamic value with this modification in place. + // if !in.Type().Equal(st.TerraformType(ctx)) { return nil, fmt.Errorf("can't use %s as value of Set with ElementType %T, can only use %s values", in.String(), st.ElementType(), st.ElementType().TerraformType(ctx).String()) } diff --git a/types/basetypes/set_value.go b/types/basetypes/set_value.go index d29a7022c..9beb2da3d 100644 --- a/types/basetypes/set_value.go +++ b/types/basetypes/set_value.go @@ -209,6 +209,19 @@ func (s SetValue) ToTerraformValue(ctx context.Context) (tftypes.Value, error) { switch s.state { case attr.ValueStateKnown: + // MAINTAINER NOTE: + // SetValue does not support DynamicType as an element type. It is not explicitly prevented from being created with the + // Framework type system, but the Framework-supported SetAttribute, SetNestedAttribute, and SetNestedBlock all prevent DynamicType + // from being used as an element type. + // + // In the future, if we ever need to support a set of dynamic element types, this tftypes.Set creation logic will need to be modified to ensure + // that known values contain the exact same concrete element type, specifically with unknown and null values. Dynamic values will return the correct concrete + // element type for known values from `elem.ToTerraformValue`, but unknown and null values will be tftypes.DynamicPseudoType, causing an error due to multiple element + // types in a tftypes.Set. + // + // Unknown and null element types of tftypes.DynamicPseudoType must be recreated as the concrete element type unknown/null value. This can be done by checking `s.elements` + // for a single concrete type (i.e. not tftypes.DynamicPseudoType), and using that concrete type to create unknown and null dynamic values later. + // vals := make([]tftypes.Value, 0, len(s.elements)) for _, elem := range s.elements { diff --git a/types/basetypes/set_value_test.go b/types/basetypes/set_value_test.go index 85228a829..28c0c08a2 100644 --- a/types/basetypes/set_value_test.go +++ b/types/basetypes/set_value_test.go @@ -555,6 +555,44 @@ func TestSetValueToTerraformValue(t *testing.T) { input: NewSetNull(StringType{}), expectation: tftypes.NewValue(tftypes.Set{ElementType: tftypes.String}, nil), }, + // In the scenario where Terraform has refined a dynamic type to a set but the element type is not known, it's possible + // to receive a set with a dynamic element type. + // + // An example configuration that demonstrates this scenario, where "dynamic_attr" is a schema.DynamicAttribute: + // + // resource "examplecloud_thing" "this" { + // dynamic_attr = toset([]) + // } + // + // And the resulting state value: + // + // "dynamic_attr": { + // "value": [], + // "type": [ + // "set", + // "dynamic" + // ] + // } + // + "known-empty-dynamic-element-type": { + input: NewSetValueMust( + DynamicType{}, + []attr.Value{}, + ), + expectation: tftypes.NewValue(tftypes.Set{ElementType: tftypes.DynamicPseudoType}, []tftypes.Value{}), + }, + "unknown-dynamic-element-type": { + input: NewSetUnknown( + DynamicType{}, + ), + expectation: tftypes.NewValue(tftypes.Set{ElementType: tftypes.DynamicPseudoType}, tftypes.UnknownValue), + }, + "null-dynamic-element-type": { + input: NewSetNull( + DynamicType{}, + ), + expectation: tftypes.NewValue(tftypes.Set{ElementType: tftypes.DynamicPseudoType}, nil), + }, } for name, test := range tests { name, test := name, test diff --git a/types/basetypes/tuple_type_test.go b/types/basetypes/tuple_type_test.go index 8423ba795..65557abbd 100644 --- a/types/basetypes/tuple_type_test.go +++ b/types/basetypes/tuple_type_test.go @@ -307,57 +307,83 @@ func TestTupleTypeValueFromTerraform(t *testing.T) { }, ), }, + "tuple-with-dynamic-types": { + receiver: TupleType{ + []attr.Type{DynamicType{}, DynamicType{}}, + }, + input: tftypes.NewValue(tftypes.Tuple{ + ElementTypes: []tftypes.Type{tftypes.DynamicPseudoType, tftypes.DynamicPseudoType}, + }, []tftypes.Value{ + tftypes.NewValue(tftypes.String, "hello"), + tftypes.NewValue(tftypes.Bool, true), + }), + expected: NewTupleValueMust( + []attr.Type{DynamicType{}, DynamicType{}}, + []attr.Value{ + NewDynamicValue(NewStringValue("hello")), + NewDynamicValue(NewBoolValue(true)), + }, + ), + }, "unknown-tuple": { receiver: TupleType{ - ElemTypes: []attr.Type{StringType{}, BoolType{}}, + ElemTypes: []attr.Type{StringType{}, BoolType{}, DynamicType{}}, }, input: tftypes.NewValue(tftypes.Tuple{ - ElementTypes: []tftypes.Type{tftypes.String, tftypes.Bool}, + ElementTypes: []tftypes.Type{tftypes.String, tftypes.Bool, tftypes.DynamicPseudoType}, }, tftypes.UnknownValue), - expected: NewTupleUnknown([]attr.Type{StringType{}, BoolType{}}), + expected: NewTupleUnknown([]attr.Type{StringType{}, BoolType{}, DynamicType{}}), }, "partially-unknown-tuple": { receiver: TupleType{ - ElemTypes: []attr.Type{StringType{}, BoolType{}}, + ElemTypes: []attr.Type{StringType{}, BoolType{}, DynamicType{}, DynamicType{}}, }, input: tftypes.NewValue(tftypes.Tuple{ - ElementTypes: []tftypes.Type{tftypes.String, tftypes.Bool}, + ElementTypes: []tftypes.Type{tftypes.String, tftypes.Bool, tftypes.DynamicPseudoType, tftypes.DynamicPseudoType}, }, []tftypes.Value{ tftypes.NewValue(tftypes.String, "hello"), tftypes.NewValue(tftypes.Bool, tftypes.UnknownValue), + tftypes.NewValue(tftypes.String, "world"), + tftypes.NewValue(tftypes.DynamicPseudoType, tftypes.UnknownValue), }), expected: NewTupleValueMust( - []attr.Type{StringType{}, BoolType{}}, + []attr.Type{StringType{}, BoolType{}, DynamicType{}, DynamicType{}}, []attr.Value{ NewStringValue("hello"), NewBoolUnknown(), + NewDynamicValue(NewStringValue("world")), + NewDynamicUnknown(), }, ), }, "null-tuple": { receiver: TupleType{ - ElemTypes: []attr.Type{StringType{}, BoolType{}}, + ElemTypes: []attr.Type{StringType{}, BoolType{}, DynamicType{}}, }, input: tftypes.NewValue(tftypes.Tuple{ - ElementTypes: []tftypes.Type{tftypes.String, tftypes.Bool}, + ElementTypes: []tftypes.Type{tftypes.String, tftypes.Bool, tftypes.DynamicPseudoType}, }, nil), - expected: NewTupleNull([]attr.Type{StringType{}, BoolType{}}), + expected: NewTupleNull([]attr.Type{StringType{}, BoolType{}, DynamicType{}}), }, "partially-null-tuple": { receiver: TupleType{ - ElemTypes: []attr.Type{StringType{}, BoolType{}}, + ElemTypes: []attr.Type{StringType{}, BoolType{}, DynamicType{}, DynamicType{}}, }, input: tftypes.NewValue(tftypes.Tuple{ - ElementTypes: []tftypes.Type{tftypes.String, tftypes.Bool}, + ElementTypes: []tftypes.Type{tftypes.String, tftypes.Bool, tftypes.DynamicPseudoType, tftypes.DynamicPseudoType}, }, []tftypes.Value{ tftypes.NewValue(tftypes.String, "hello"), tftypes.NewValue(tftypes.Bool, nil), + tftypes.NewValue(tftypes.String, "world"), + tftypes.NewValue(tftypes.DynamicPseudoType, nil), }), expected: NewTupleValueMust( - []attr.Type{StringType{}, BoolType{}}, + []attr.Type{StringType{}, BoolType{}, DynamicType{}, DynamicType{}}, []attr.Value{ NewStringValue("hello"), NewBoolNull(), + NewDynamicValue(NewStringValue("world")), + NewDynamicNull(), }, ), }, diff --git a/types/basetypes/tuple_value_test.go b/types/basetypes/tuple_value_test.go index b7da25f2a..34512babf 100644 --- a/types/basetypes/tuple_value_test.go +++ b/types/basetypes/tuple_value_test.go @@ -658,50 +658,62 @@ func TestTupleValueToTerraformValue(t *testing.T) { tests := map[string]testCase{ "known": { input: NewTupleValueMust( - []attr.Type{StringType{}, BoolType{}}, + []attr.Type{StringType{}, BoolType{}, DynamicType{}, DynamicType{}}, []attr.Value{ NewStringValue("hello"), NewBoolValue(true), + NewDynamicValue(NewStringValue("world")), + NewDynamicValue(NewBoolValue(false)), }, ), - expectation: tftypes.NewValue(tftypes.Tuple{ElementTypes: []tftypes.Type{tftypes.String, tftypes.Bool}}, []tftypes.Value{ + expectation: tftypes.NewValue(tftypes.Tuple{ElementTypes: []tftypes.Type{tftypes.String, tftypes.Bool, tftypes.DynamicPseudoType, tftypes.DynamicPseudoType}}, []tftypes.Value{ tftypes.NewValue(tftypes.String, "hello"), tftypes.NewValue(tftypes.Bool, true), + tftypes.NewValue(tftypes.String, "world"), + tftypes.NewValue(tftypes.Bool, false), }), }, "known-partial-unknown": { input: NewTupleValueMust( - []attr.Type{StringType{}, BoolType{}}, + []attr.Type{StringType{}, BoolType{}, DynamicType{}, DynamicType{}}, []attr.Value{ NewStringValue("hello"), NewBoolUnknown(), + NewDynamicValue(NewStringValue("world")), + NewDynamicUnknown(), }, ), - expectation: tftypes.NewValue(tftypes.Tuple{ElementTypes: []tftypes.Type{tftypes.String, tftypes.Bool}}, []tftypes.Value{ + expectation: tftypes.NewValue(tftypes.Tuple{ElementTypes: []tftypes.Type{tftypes.String, tftypes.Bool, tftypes.DynamicPseudoType, tftypes.DynamicPseudoType}}, []tftypes.Value{ tftypes.NewValue(tftypes.String, "hello"), tftypes.NewValue(tftypes.Bool, tftypes.UnknownValue), + tftypes.NewValue(tftypes.String, "world"), + tftypes.NewValue(tftypes.DynamicPseudoType, tftypes.UnknownValue), }), }, "known-partial-null": { input: NewTupleValueMust( - []attr.Type{StringType{}, BoolType{}}, + []attr.Type{StringType{}, BoolType{}, DynamicType{}, DynamicType{}}, []attr.Value{ NewStringNull(), NewBoolValue(true), + NewDynamicValue(NewStringValue("world")), + NewDynamicNull(), }, ), - expectation: tftypes.NewValue(tftypes.Tuple{ElementTypes: []tftypes.Type{tftypes.String, tftypes.Bool}}, []tftypes.Value{ + expectation: tftypes.NewValue(tftypes.Tuple{ElementTypes: []tftypes.Type{tftypes.String, tftypes.Bool, tftypes.DynamicPseudoType, tftypes.DynamicPseudoType}}, []tftypes.Value{ tftypes.NewValue(tftypes.String, nil), tftypes.NewValue(tftypes.Bool, true), + tftypes.NewValue(tftypes.String, "world"), + tftypes.NewValue(tftypes.DynamicPseudoType, nil), }), }, "unknown": { - input: NewTupleUnknown([]attr.Type{StringType{}, BoolType{}}), - expectation: tftypes.NewValue(tftypes.Tuple{ElementTypes: []tftypes.Type{tftypes.String, tftypes.Bool}}, tftypes.UnknownValue), + input: NewTupleUnknown([]attr.Type{StringType{}, BoolType{}, DynamicType{}}), + expectation: tftypes.NewValue(tftypes.Tuple{ElementTypes: []tftypes.Type{tftypes.String, tftypes.Bool, tftypes.DynamicPseudoType}}, tftypes.UnknownValue), }, "null": { - input: NewTupleNull([]attr.Type{StringType{}, BoolType{}}), - expectation: tftypes.NewValue(tftypes.Tuple{ElementTypes: []tftypes.Type{tftypes.String, tftypes.Bool}}, nil), + input: NewTupleNull([]attr.Type{StringType{}, BoolType{}, DynamicType{}}), + expectation: tftypes.NewValue(tftypes.Tuple{ElementTypes: []tftypes.Type{tftypes.String, tftypes.Bool, tftypes.DynamicPseudoType}}, nil), }, } for name, test := range tests { diff --git a/types/dynamic_type.go b/types/dynamic_type.go new file mode 100644 index 000000000..f63d30cbc --- /dev/null +++ b/types/dynamic_type.go @@ -0,0 +1,8 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package types + +import "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + +var DynamicType = basetypes.DynamicType{} diff --git a/types/dynamic_value.go b/types/dynamic_value.go new file mode 100644 index 000000000..845cd377a --- /dev/null +++ b/types/dynamic_value.go @@ -0,0 +1,29 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package types + +import ( + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" +) + +type Dynamic = basetypes.DynamicValue + +// DynamicNull creates a Dynamic with a null value. Determine whether the value is +// null via the Dynamic type IsNull method. +func DynamicNull() basetypes.DynamicValue { + return basetypes.NewDynamicNull() +} + +// DynamicUnknown creates a Dynamic with an unknown value. Determine whether the +// value is unknown via the Dynamic type IsUnknown method. +func DynamicUnknown() basetypes.DynamicValue { + return basetypes.NewDynamicUnknown() +} + +// DynamicValue creates a Dynamic with a known value. Access the value via the Dynamic +// type UnderlyingValue method. +func DynamicValue(value attr.Value) basetypes.DynamicValue { + return basetypes.NewDynamicValue(value) +} diff --git a/website/data/plugin-framework-nav-data.json b/website/data/plugin-framework-nav-data.json index b5e827a16..6d63120b7 100644 --- a/website/data/plugin-framework-nav-data.json +++ b/website/data/plugin-framework-nav-data.json @@ -148,6 +148,10 @@ "title": "Bool", "path": "functions/parameters/bool" }, + { + "title": "Dynamic", + "path": "functions/parameters/dynamic" + }, { "title": "Float64", "path": "functions/parameters/float64" @@ -193,6 +197,10 @@ "title": "Bool", "path": "functions/returns/bool" }, + { + "title": "Dynamic", + "path": "functions/returns/dynamic" + }, { "title": "Float64", "path": "functions/returns/float64" @@ -263,6 +271,10 @@ "title": "Bool", "path": "handling-data/attributes/bool" }, + { + "title": "Dynamic", + "path": "handling-data/attributes/dynamic" + }, { "title": "Float64", "path": "handling-data/attributes/float64" @@ -345,6 +357,10 @@ "title": "Bool", "path": "handling-data/types/bool" }, + { + "title": "Dynamic", + "path": "handling-data/types/dynamic" + }, { "title": "Float64", "path": "handling-data/types/float64" @@ -402,6 +418,10 @@ { "title": "Writing Data", "path": "handling-data/writing-state" + }, + { + "title": "Dynamic Data", + "path": "handling-data/dynamic-data" } ] }, diff --git a/website/docs/plugin/framework/functions/parameters/dynamic.mdx b/website/docs/plugin/framework/functions/parameters/dynamic.mdx new file mode 100644 index 000000000..a4e2f82ed --- /dev/null +++ b/website/docs/plugin/framework/functions/parameters/dynamic.mdx @@ -0,0 +1,176 @@ +--- +page_title: 'Plugin Development - Framework: Dynamic Function Parameter' +description: >- + Learn the dynamic function parameter type in the provider development framework. +--- + +# Dynamic Function Parameter + + + +Static types should always be preferred over dynamic types, when possible. + +Developers creating a function with a dynamic parameter will need to have extensive knowledge of the [Terraform type system](/terraform/language/expressions/types), as no type conversion will be performed to incoming argument data. + +Refer to [Dynamic Data - Considerations](/terraform/plugin/framework/handling-data/dynamic-data#considerations) for more information. + + + +Dynamic function parameters can receive **any** value type from a practitioner configuration. Values are accessible in function logic by the [framework dynamic type](/terraform/plugin/framework/handling-data/types/dynamic). + +In this Terraform configuration example, a dynamic parameter is set to the boolean value `true`: + +```hcl +provider::example::example(true) +``` + +In this example, the same dynamic parameter is set to a tuple (not a list) of string values `one` and `two`: + +```hcl +provider::example::example(["one", "two"]) +``` + +In this example, the same dynamic parameter is set to an object type with mapped values of `attr1` to `"value1"` and `attr2` to `123`: + +```hcl +provider::example::example({ + attr1 = "value1", + attr2 = 123, +}) +``` + +## Function Definition + +Use the [`function.DynamicParameter` type](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/function#DynamicParameter) in the [function definition](/terraform/plugin/framework/functions/implementation#definition-method) to accept a dynamic value. + +In this example, a function definition includes a first position dynamic parameter: + +```go +func (f ExampleFunction) Definition(ctx context.Context, req function.DefinitionRequest, resp *function.DefinitionResponse) { + resp.Definition = function.Definition{ + // ... other Definition fields ... + Parameters: []function.Parameter{ + function.DynamicParameter{ + Name: "dynamic_param", + // ... potentially other DynamicParameter fields ... + }, + }, + } +} +``` + +Dynamic values are not supported as the element type of a [collection type](/terraform/plugin/framework/handling-data/types#collection-types) or within [collection parameter types](/terraform/plugin/framework/functions/parameters#collection-parameter-types). + +If the dynamic value should be a value type of an [object parameter type](/terraform/plugin/framework/functions/parameters#object-parameter-type), set the `AttributeTypes` map value according to the [framework dynamic type](/terraform/plugin/framework/handling-data/types/dynamic). Refer to the object parameter type documentation for additional details. + +### Allow Null Values + + + +A known dynamic value with an underlying value that contains nulls (such as a list with null element values) will always be sent to the function logic, regardless of the `AllowNullValue` setting. Data handling must always account for this situation. + + + +By default, Terraform will not pass null values to the function logic. Use the `AllowNullValue` field to explicitly allow null values, if there is a meaningful distinction that should occur in function logic. + +### Allow Unknown Values + +By default, Terraform will not pass unknown values to the function logic. Use the `AllowUnknownValues` field to explicitly allow unknown values, if there is a meaningful distinction that should occur in function logic. + +### Custom Types + +You may want to build your own data value and type implementations to allow your provider to combine validation and other behaviors into a reusable bundle. This helps avoid duplication and ensures consistency. These implementations use the `CustomType` field in the parameter type. + +Refer to [Custom Types](/terraform/plugin/framework/handling-data/types/custom) for further details on creating provider-defined types and values. + +### Documentation + +Refer to [function documentation](/terraform/plugin/framework/functions/documentation) for information about the `Name`, `Description`, and `MarkdownDescription` fields available. + +## Reading Argument Data + +The [function implementation](/terraform/plugin/framework/functions/implementation) documentation covers the general methods for reading function argument data in function logic. + +When retrieving the argument value for this parameter: + +* If `CustomType` is set, use its associated value type. +* Otherwise, you must use the [framework dynamic type](/terraform/plugin/framework/handling-data/types/dynamic). + +In this example, a function defines a single dynamic parameter and accesses its argument value: + +```go +func (f ExampleFunction) Definition(ctx context.Context, req function.DefinitionRequest, resp *function.DefinitionResponse) { + resp.Definition = function.Definition{ + // ... other Definition fields ... + Parameters: []function.Parameter{ + function.DynamicParameter{ + Name: "dynamic_param", + // ... potentially other DynamicParameter fields ... + }, + }, + } +} + +func (f ExampleFunction) Run(ctx context.Context, req function.RunRequest, resp *function.RunResponse) { + var dynamicArg types.Dynamic + + resp.Error = function.ConcatFuncErrors(resp.Error, req.Arguments.Get(ctx, &dynamicArg)) + + // dynamicArg is now populated + // ... other logic ... +} +``` + +For more detail on working with dynamic values, see the [framework dynamic type](/terraform/plugin/framework/handling-data/types/dynamic) documentation. + +## Using Dynamic as a Variadic Parameter + +Utilizing `function.DynamicParameter` in the [`VariadicParameter`](/terraform/plugin/framework/functions/implementation#reading-variadic-parameter-argument-data) field will allow zero, one, or more values of **potentially different** types. + +To handle this scenario of multiple values with different types, utilize [`types.Tuple`](/terraform/plugin/framework/handling-data/types/tuple) or [`[]types.Dynamic`](/terraform/plugin/framework/handling-data/types/dynamic) when reading a dynamic variadic argument. + +```go +func (f *ExampleFunction) Definition(ctx context.Context, req function.DefinitionRequest, resp *function.DefinitionResponse) { + resp.Definition = function.Definition{ + // ... other Definition fields ... + VariadicParameter: function.DynamicParameter{ + Name: "variadic_param", + }, + } +} + +func (f *ExampleFunction) Run(ctx context.Context, req function.RunRequest, resp *function.RunResponse) { + var dynValues []types.Dynamic + + resp.Error = function.ConcatFuncErrors(resp.Error, req.Arguments.Get(ctx, &dynValues)) + if resp.Error != nil { + return + } + + for _, dynValue := range dynValues { + if dynValue.IsNull() || dynValue.IsUnknown() { + continue + } + // ... do something with argument value, i.e. dynValue.UnderlyingValue() ... + } + + // ... other logic ... +} + +``` + +In these Terraform configuration examples, the function variadic argument will receive the following value types: + +```hcl +# []types.Dynamic{} +provider::example::example() + +# []types.Dynamic{types.String} +provider::example::example("hello world") + +# []types.Dynamic{types.Bool, types.Number} +provider::example::example(true, 1) + +# []types.Dynamic{types.String, types.Tuple[types.String, types.String], types.List[types.String]} +provider::example::example("hello", ["one", "two"], tolist(["one", "two"])) +``` diff --git a/website/docs/plugin/framework/functions/parameters/index.mdx b/website/docs/plugin/framework/functions/parameters/index.mdx index 445d2b234..9150b532d 100644 --- a/website/docs/plugin/framework/functions/parameters/index.mdx +++ b/website/docs/plugin/framework/functions/parameters/index.mdx @@ -16,6 +16,7 @@ Function definitions support the following parameter types: - [Primitive](#primitive-parameter-types): Parameter that accepts a single value, such as a boolean, number, or string. - [Collection](#collection-parameter-types): Parameter that accepts multiple values of a single element type, such as a list, map, or set. - [Object](#object-parameter-type): Parameter that accepts a structure of explicit attribute names. +- [Dynamic](#dynamic-parameter-type): Parameter that accepts any value type. ### Primitive Parameter Types @@ -29,7 +30,7 @@ Parameter types that accepts a single data value, such as a boolean, number, or | [Number](/terraform/plugin/framework/functions/parameters/number) | Arbitrary precision (generally over 64-bit, up to 512-bit) number | | [String](/terraform/plugin/framework/functions/parameters/string) | Collection of UTF-8 encoded characters | -#### Collection Parameter Types +### Collection Parameter Types Parameter types that accepts multiple values of a single element type, such as a list, map, or set. @@ -39,7 +40,7 @@ Parameter types that accepts multiple values of a single element type, such as a | [Map](/terraform/plugin/framework/functions/parameters/map) | Mapping of arbitrary string keys to values of single element type | | [Set](/terraform/plugin/framework/functions/parameters/set) | Unordered, unique collection of single element type | -#### Object Parameter Type +### Object Parameter Type Parameter type that accepts a structure of explicit attribute names. @@ -47,6 +48,20 @@ Parameter type that accepts a structure of explicit attribute names. |----------------|----------| | [Object](/terraform/plugin/framework/functions/parameters/object) | Single structure mapping explicit attribute names | +### Dynamic Parameter Type + + + +Dynamic value handling is an advanced use case. Prefer static parameter types when possible unless absolutely necessary for your use case. + + + +Parameter that accepts any value type, determined by Terraform at runtime. + +| Parameter Type | Use Case | +|----------------|----------| +| [Dynamic](/terraform/plugin/framework/functions/parameters/dynamic) | Accept any value type of data, determined at runtime. | + ## Parameter Naming All parameter types have a `Name` field that is **required**. @@ -78,4 +93,4 @@ Parameter names are used in runtime errors to highlight which parameter is causi │ │ while calling provider::example::example_function(bool_param) │ │ Invalid value for "bool_param" parameter: a bool is required. -``` \ No newline at end of file +``` diff --git a/website/docs/plugin/framework/functions/returns/bool.mdx b/website/docs/plugin/framework/functions/returns/bool.mdx index c20a5a27e..e2773b5ee 100644 --- a/website/docs/plugin/framework/functions/returns/bool.mdx +++ b/website/docs/plugin/framework/functions/returns/bool.mdx @@ -60,6 +60,6 @@ func (f ExampleFunction) Run(ctx context.Context, req function.RunRequest, resp // hardcoded value for example brevity result := true - resp.Error = function.ConcatFuncErrors(resp.Error, req.Arguments.Set(ctx, &result)) + resp.Error = function.ConcatFuncErrors(resp.Error, resp.Result.Set(ctx, &result)) } ``` diff --git a/website/docs/plugin/framework/functions/returns/dynamic.mdx b/website/docs/plugin/framework/functions/returns/dynamic.mdx new file mode 100644 index 000000000..d9555abde --- /dev/null +++ b/website/docs/plugin/framework/functions/returns/dynamic.mdx @@ -0,0 +1,77 @@ +--- +page_title: 'Plugin Development - Framework: Dynamic Function Return' +description: >- + Learn the dynamic function return type in the provider development framework. +--- + +# Dynamic Function Return + + + +Static types should always be preferred over dynamic types, when possible. + +Developers creating a function with a dynamic return will need to have extensive knowledge of the [Terraform type system](/terraform/language/expressions/types) to understand how the value type returned can impact practitioner configuration. + +Refer to [Dynamic Data - Considerations](/terraform/plugin/framework/handling-data/dynamic-data#considerations) for more information. + + + +Dynamic function return can be **any** value type from function logic. Set values in function logic with the [framework dynamic type](/terraform/plugin/framework/handling-data/types/dynamic). + +## Function Definition + +Use the [`function.DynamicReturn` type](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/function#DynamicReturn) in the [function definition](/terraform/plugin/framework/functions/implementation#definition-method). + +In this example, a function definition includes a dynamic return: + +```go +func (f ExampleFunction) Definition(ctx context.Context, req function.DefinitionRequest, resp *function.DefinitionResponse) { + resp.Definition = function.Definition{ + // ... other Definition fields ... + Return: function.DynamicReturn{ + // ... potentially other DynamicReturn fields ... + }, + } +} +``` + +### Custom Types + +You may want to build your own data value and type implementations to allow your provider to combine validation and other behaviors into a reusable bundle. This helps avoid duplication and ensures consistency. These implementations use the `CustomType` field in the return type. + +Refer to [Custom Types](/terraform/plugin/framework/handling-data/types/custom) for further details on creating provider-defined types and values. + +### Documentation + +Return documentation is expected in the top-level function documentation. Refer to [function documentation](/terraform/plugin/framework/functions/documentation) for information about the `Summary`, `Description`, and `MarkdownDescription` fields available. + +## Setting Return Data + +The [function implementation](/terraform/plugin/framework/functions/implementation) documentation covers the general methods for setting function return data in function logic. + +When setting the value for this return: + +* If `CustomType` is set, use its associated value type. +* Otherwise, use the [framework dynamic type](/terraform/plugin/framework/handling-data/types/dynamic). + +In this example, a function defines a dynamic return and sets its value to a string: + +```go +func (f ExampleFunction) Definition(ctx context.Context, req function.DefinitionRequest, resp *function.DefinitionResponse) { + resp.Definition = function.Definition{ + // ... other Definition fields ... + Return: function.DynamicReturn{}, + } +} + +func (f ExampleFunction) Run(ctx context.Context, req function.RunRequest, resp *function.RunResponse) { + // ... other logic ... + + // hardcoded value for example brevity + result := types.DynamicValue(types.StringValue("hello world!")) + + resp.Error = function.ConcatFuncErrors(resp.Error, resp.Result.Set(ctx, &result)) +} +``` + +For more detail on working with dynamic values, see the [framework dynamic type](/terraform/plugin/framework/handling-data/types/dynamic) documentation. diff --git a/website/docs/plugin/framework/functions/returns/float64.mdx b/website/docs/plugin/framework/functions/returns/float64.mdx index b60f1fe29..edff1988a 100644 --- a/website/docs/plugin/framework/functions/returns/float64.mdx +++ b/website/docs/plugin/framework/functions/returns/float64.mdx @@ -66,6 +66,6 @@ func (f ExampleFunction) Run(ctx context.Context, req function.RunRequest, resp // hardcoded value for example brevity result := 1.23 - resp.Error = function.ConcatFuncErrors(resp.Error, req.Arguments.Set(ctx, &result)) + resp.Error = function.ConcatFuncErrors(resp.Error, resp.Result.Set(ctx, &result)) } ``` diff --git a/website/docs/plugin/framework/functions/returns/index.mdx b/website/docs/plugin/framework/functions/returns/index.mdx index f07a7c962..fc2921871 100644 --- a/website/docs/plugin/framework/functions/returns/index.mdx +++ b/website/docs/plugin/framework/functions/returns/index.mdx @@ -16,6 +16,7 @@ Function definitions support the following return types: - [Primitive](#primitive-return-types): Return that expects a single value, such as a boolean, number, or string. - [Collection](#collection-return-types): Return that expects multiple values of a single element type, such as a list, map, or set. - [Object](#object-return-type): Return that expects a structure of explicit attribute names. +- [Dynamic](#dynamic-return-type): Return that can be any value type. ### Primitive Return Types @@ -29,7 +30,7 @@ Return types that expect a single data value, such as a boolean, number, or stri | [Number](/terraform/plugin/framework/functions/returns/number) | Arbitrary precision (generally over 64-bit, up to 512-bit) number | | [String](/terraform/plugin/framework/functions/returns/string) | Collection of UTF-8 encoded characters | -#### Collection Return Types +### Collection Return Types Return types that expect multiple values of a single element type, such as a list, map, or set. @@ -39,10 +40,18 @@ Return types that expect multiple values of a single element type, such as a lis | [Map](/terraform/plugin/framework/functions/returns/map) | Mapping of arbitrary string keys to values of single element type | | [Set](/terraform/plugin/framework/functions/returns/set) | Unordered, unique collection of single element type | -#### Object Return Type +### Object Return Type Return type that expects a structure of explicit attribute names. | Return Type | Use Case | |----------------|----------| | [Object](/terraform/plugin/framework/functions/returns/object) | Single structure mapping explicit attribute names | + +### Dynamic Return Type + +Return type that can be any value type, determined by the provider at runtime. + +| Return Type | Use Case | +|----------------|----------| +| [Dynamic](/terraform/plugin/framework/functions/returns/dynamic) | Return any value type of data, determined at runtime. | \ No newline at end of file diff --git a/website/docs/plugin/framework/functions/returns/int64.mdx b/website/docs/plugin/framework/functions/returns/int64.mdx index 9a24ca6e2..25d06f532 100644 --- a/website/docs/plugin/framework/functions/returns/int64.mdx +++ b/website/docs/plugin/framework/functions/returns/int64.mdx @@ -66,6 +66,6 @@ func (f ExampleFunction) Run(ctx context.Context, req function.RunRequest, resp // hardcoded value for example brevity result := 123 - resp.Error = function.ConcatFuncErrors(resp.Error, req.Arguments.Set(ctx, &result)) + resp.Error = function.ConcatFuncErrors(resp.Error, resp.Result.Set(ctx, &result)) } ``` diff --git a/website/docs/plugin/framework/functions/returns/list.mdx b/website/docs/plugin/framework/functions/returns/list.mdx index 625486ef5..1a1e423c3 100644 --- a/website/docs/plugin/framework/functions/returns/list.mdx +++ b/website/docs/plugin/framework/functions/returns/list.mdx @@ -65,6 +65,6 @@ func (f ExampleFunction) Run(ctx context.Context, req function.RunRequest, resp // hardcoded value for example brevity result := []string{"one", "two"} - resp.Error = function.ConcatFuncErrors(resp.Error, req.Arguments.Set(ctx, &result)) + resp.Error = function.ConcatFuncErrors(resp.Error, resp.Result.Set(ctx, &result)) } ``` diff --git a/website/docs/plugin/framework/functions/returns/map.mdx b/website/docs/plugin/framework/functions/returns/map.mdx index e95549bea..71840f7fb 100644 --- a/website/docs/plugin/framework/functions/returns/map.mdx +++ b/website/docs/plugin/framework/functions/returns/map.mdx @@ -68,6 +68,6 @@ func (f ExampleFunction) Run(ctx context.Context, req function.RunRequest, resp "key2": "value2", } - resp.Error = function.ConcatFuncErrors(resp.Error, req.Arguments.Set(ctx, &result)) + resp.Error = function.ConcatFuncErrors(resp.Error, resp.Result.Set(ctx, &result)) } ``` diff --git a/website/docs/plugin/framework/functions/returns/number.mdx b/website/docs/plugin/framework/functions/returns/number.mdx index f05419f70..2d5295109 100644 --- a/website/docs/plugin/framework/functions/returns/number.mdx +++ b/website/docs/plugin/framework/functions/returns/number.mdx @@ -66,6 +66,6 @@ func (f ExampleFunction) Run(ctx context.Context, req function.RunRequest, resp // hardcoded value for example brevity result := big.NewFloat(1.23) - resp.Error = function.ConcatFuncErrors(resp.Error, req.Arguments.Set(ctx, &result)) + resp.Error = function.ConcatFuncErrors(resp.Error, resp.Result.Set(ctx, &result)) } ``` diff --git a/website/docs/plugin/framework/functions/returns/object.mdx b/website/docs/plugin/framework/functions/returns/object.mdx index a8ceb1975..59262f4b2 100644 --- a/website/docs/plugin/framework/functions/returns/object.mdx +++ b/website/docs/plugin/framework/functions/returns/object.mdx @@ -77,6 +77,6 @@ func (f ExampleFunction) Run(ctx context.Context, req function.RunRequest, resp Attr2: 123, } - resp.Error = function.ConcatFuncErrors(resp.Error, req.Arguments.Set(ctx, &result)) + resp.Error = function.ConcatFuncErrors(resp.Error, resp.Result.Set(ctx, &result)) } ``` diff --git a/website/docs/plugin/framework/functions/returns/set.mdx b/website/docs/plugin/framework/functions/returns/set.mdx index 00a4211c7..622fb44b3 100644 --- a/website/docs/plugin/framework/functions/returns/set.mdx +++ b/website/docs/plugin/framework/functions/returns/set.mdx @@ -65,6 +65,6 @@ func (f ExampleFunction) Run(ctx context.Context, req function.RunRequest, resp // hardcoded value for example brevity result := []string{"one", "two"} - resp.Error = function.ConcatFuncErrors(resp.Error, req.Arguments.Set(ctx, &result)) + resp.Error = function.ConcatFuncErrors(resp.Error, resp.Result.Set(ctx, &result)) } ``` diff --git a/website/docs/plugin/framework/functions/returns/string.mdx b/website/docs/plugin/framework/functions/returns/string.mdx index bfed72434..8daf2b2aa 100644 --- a/website/docs/plugin/framework/functions/returns/string.mdx +++ b/website/docs/plugin/framework/functions/returns/string.mdx @@ -60,6 +60,6 @@ func (f ExampleFunction) Run(ctx context.Context, req function.RunRequest, resp // hardcoded value for example brevity result := "example" - resp.Error = function.ConcatFuncErrors(resp.Error, req.Arguments.Set(ctx, &result)) + resp.Error = function.ConcatFuncErrors(resp.Error, resp.Result.Set(ctx, &result)) } ``` diff --git a/website/docs/plugin/framework/handling-data/attributes/dynamic.mdx b/website/docs/plugin/framework/handling-data/attributes/dynamic.mdx new file mode 100644 index 000000000..f399ce665 --- /dev/null +++ b/website/docs/plugin/framework/handling-data/attributes/dynamic.mdx @@ -0,0 +1,146 @@ +--- +page_title: 'Plugin Development - Framework: Dynamic Attribute' +description: >- + Learn the dynamic attribute type in the provider development framework. +--- + +# Dynamic Attribute + + + +Static attribute types should always be preferred over dynamic attribute types, when possible. + +Developers dealing with dynamic attribute data will need to have extensive knowledge of the [Terraform type system](/terraform/language/expressions/types) to properly handle all potential practitioner configuration scenarios. + +Refer to [Dynamic Data - Considerations](/terraform/plugin/framework/handling-data/dynamic-data#considerations) for more information. + + + +Dynamic attributes can store **any** value. Values are represented by a [dynamic type](/terraform/plugin/framework/handling-data/types/dynamic) in the framework. + +In this Terraform configuration example, a dynamic attribute named `example_attribute` is set to the boolean value `true`: + +```hcl +resource "examplecloud_thing" "example" { + example_attribute = true +} +``` + +In this example, the same dynamic attribute is set to a tuple (not a list) of string values `one` and `two`: + +```hcl +resource "examplecloud_thing" "example" { + example_attribute = ["one", "two"] +} +``` + +In this example, the same dynamic attribute is set to an object type with mapped values of `attr1` to `"value1"` and `attr2` to `123`: + +```hcl +resource "examplecloud_thing" "example" { + example_attribute = { + attr1 = "value1" + attr2 = 123 + } +} +``` + + +## Schema Definition + +Use one of the following attribute types to directly add a dynamic value to a [schema](/terraform/plugin/framework/handling-data/schemas) or a [single nested attribute type](/terraform/plugin/framework/handling-data/attributes/single-nested): + +| Schema Type | Attribute Type | +|-------------|----------------| +| [Data Source](/terraform/plugin/framework/data-sources) | [`schema.DynamicAttribute`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/datasource/schema#DynamicAttribute) | +| [Provider](/terraform/plugin/framework/provider) | [`schema.DynamicAttribute`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/provider/schema#DynamicAttribute) | +| [Resource](/terraform/plugin/framework/resources) | [`schema.DynamicAttribute`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/resource/schema#DynamicAttribute) | + +In this example, a resource schema defines a top level required dynamic attribute named `example_attribute`: + +```go +func (r ThingResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attribute": schema.DynamicAttribute{ + Required: true, + // ... potentially other fields ... + }, + // ... potentially other attributes ... + }, + } +} +``` + +Dynamic values are not supported as the element type of a [collection type](/terraform/plugin/framework/handling-data/types#collection-types) or within [collection attribute types](/terraform/plugin/framework/handling-data/attributes#collection-attribute-types). + +If the dynamic value should be a value type of an [object attribute type](/terraform/plugin/framework/handling-data/attributes#object-attribute-type), set the `AttributeTypes` map value according to the [dynamic type](/terraform/plugin/framework/handling-data/types/dynamic). Refer to the object attribute type documentation for additional details. + +### Configurability + +At least one of the `Computed`, `Optional`, or `Required` fields must be set to `true`. This defines how Terraform and the framework should expect data to set, whether the value is from the practitioner configuration or from the provider logic, such as API response value. + +The acceptable behaviors of these configurability options are: + +- `Required` only: The value must be practitioner configured to an eventually known value (not null), otherwise the framework will automatically raise an error diagnostic for the missing value. +- `Optional` only: The value may be practitioner configured to a known value or null. +- `Optional` and `Computed`: The value may be practitioner configured or the value may be set in provider logic when the practitioner configuration is null. +- `Computed` only: The value will be set in provider logic and any practitioner configuration causes the framework to automatically raise an error diagnostic for the unexpected configuration value. + +### Custom Types + +You may want to build your own attribute value and type implementations to allow your provider to combine validation, description, and plan customization behaviors into a reusable bundle. This helps avoid duplication or reimplementation and ensures consistency. These implementations use the `CustomType` field in the attribute type. + +Refer to [Custom Types](/terraform/plugin/framework/handling-data/types/custom) for further details on creating provider-defined types and values. + +### Deprecation + +Set the `DeprecationMessage` field to a practitioner-focused message for how to handle the deprecation. The framework will automatically raise a warning diagnostic with this message if the practitioner configuration contains a known value for the attribute. Terraform version 1.2.7 and later will raise a warning diagnostic in certain scenarios if the deprecated attribute value is referenced elsewhere in a practitioner configuration. The framework [deprecations](/terraform/plugin/framework/deprecations) documentation fully describes the recommended practices for deprecating an attribute or resource. + +Some practitioner-focused examples of a deprecation message include: + +- Configure `other_attribute` instead. This attribute will be removed in the next major version of the provider. +- Remove this attribute's configuration as it no longer is used and the attribute will be removed in the next major version of the provider. + +### Description + +The framework provides two description fields, `Description` and `MarkdownDescription`, which various tools use to show additional information about an attribute and its intended purpose. This includes, but is not limited to, [`terraform-plugin-docs`](https://github.com/hashicorp/terraform-plugin-docs) for automated provider documentation generation and [`terraform-ls`](https://github.com/hashicorp/terraform-ls) for Terraform configuration editor integrations. + +### Plan Modification + + + +Only managed resources implement this concept. + + + +The framework provides two plan modification fields for managed resource attributes, `Default` and `PlanModifiers`, which define resource and attribute value planning behaviors. The resource [default](/terraform/plugin/framework/resources/default) and [plan modification](/terraform/plugin/framework/resources/plan-modification) documentation covers these features more in-depth. + +#### Common Use Case Plan Modification + +The [`dynamicdefault`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/resource/schema/dynamicdefault) package defines common use case `Default` implementations: + +- [`StaticValue(types.Dynamic)`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/resource/schema/dynamicdefault#StaticValue): Define a static default value for the attribute. + +The [`dynamicplanmodifier`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/resource/schema/dynamicplanmodifier) package defines common use case `PlanModifiers` implementations: + +- [`RequiresReplace()`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/resource/schema/dynamicplanmodifier#RequiresReplace): Marks the resource for replacement if the resource is being updated and the plan value does not match the prior state value. +- [`RequiresReplaceIf()`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/resource/schema/dynamicplanmodifier#RequiresReplaceIf): Similar to `RequiresReplace()`, but also checks if a given function returns true. +- [`RequiresReplaceIfConfigured()`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/resource/schema/dynamicplanmodifier#RequiresReplaceIfConfigured): Similar to `RequiresReplace()`, but also checks if the configuration value is not null. +- [`UseStateForUnknown()`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/resource/schema/dynamicplanmodifier#UseStateForUnknown): Copies a known prior state value into the planned value. Use this when it is known that an unconfigured value will remain the same after a resource update. + +### Sensitive + +Set the `Sensitive` field if the attribute value should always be considered [sensitive data](/terraform/language/state/sensitive-data). In Terraform, this will generally mask the value in practitioner output. This setting cannot be conditionally set and does not impact how data is stored in the state. + +### Validation + +Set the `Validators` field to define [validation](/terraform/plugin/framework/validation#attribute-validation). This validation logic is ran in addition to any validation contained within a [custom type](#custom-types). + +## Accessing Values + +The [accessing values](/terraform/plugin/framework/handling-data/accessing-values) documentation covers general methods for reading [schema](/terraform/plugin/framework/handling-data/schemas) (configuration, plan, and state) data, which is necessary before accessing an attribute value directly. The [dynamic type](/terraform/plugin/framework/handling-data/types/dynamic#accessing-values) documentation covers methods for interacting with the attribute value itself. + +## Setting Values + +The [dynamic type](/terraform/plugin/framework/handling-data/types/dynamic#setting-values) documentation covers methods for creating or setting the appropriate value. The [writing data](/terraform/plugin/framework/handling-data/writing-state) documentation covers general methods for writing [schema](/terraform/plugin/framework/handling-data/schemas) (plan and state) data, which is necessary afterwards. diff --git a/website/docs/plugin/framework/handling-data/attributes/index.mdx b/website/docs/plugin/framework/handling-data/attributes/index.mdx index 20c9cbf4d..7f6bcafc6 100644 --- a/website/docs/plugin/framework/handling-data/attributes/index.mdx +++ b/website/docs/plugin/framework/handling-data/attributes/index.mdx @@ -17,6 +17,7 @@ Schemas support the following attribute types: - [Collection](#collection-attribute-types): Attribute that contains multiple values of a single element type, such as a list, map, or set. - [Nested](#nested-attribute-types): Attribute that defines a structure of explicit attibute names to attribute definitions, potentially with a wrapping collection type, such as a single structure of attributes or a list of structures of attributes. - [Object](#object-attribute-type): Attribute that defines a structure of explicit attribute names to type-only definitions. +- [Dynamic](#dynamic-attribute-type): Attribute that accepts any value type. ### Primitive Attribute Types @@ -30,7 +31,7 @@ Attribute types that contain a single data value, such as a boolean, number, or | [Number](/terraform/plugin/framework/handling-data/attributes/number) | Arbitrary precision (generally over 64-bit, up to 512-bit) number | | [String](/terraform/plugin/framework/handling-data/attributes/string) | Collection of UTF-8 encoded characters | -#### Collection Attribute Types +### Collection Attribute Types Attribute types that contain multiple values of a single element type, such as a list, map, or set. @@ -40,7 +41,7 @@ Attribute types that contain multiple values of a single element type, such as a | [Map](/terraform/plugin/framework/handling-data/attributes/map) | Mapping of arbitrary string keys to values of single element type | | [Set](/terraform/plugin/framework/handling-data/attributes/set) | Unordered, unique collection of single element type | -#### Nested Attribute Types +### Nested Attribute Types @@ -57,7 +58,7 @@ Attribute types that define a structure of explicit attibute names to attribute | [Set Nested](/terraform/plugin/framework/handling-data/attributes/set-nested) | Unordered, unique collection of structures of attributes | | [Single Nested](/terraform/plugin/framework/handling-data/attributes/single-nested) | Single structure of attributes | -#### Object Attribute Type +### Object Attribute Type @@ -70,3 +71,21 @@ Attribute type that defines a structure of explicit attribute names to type-only | Attribute Type | Use Case | |----------------|----------| | [Object](/terraform/plugin/framework/handling-data/attributes/object) | Single structure mapping explicit attribute names to type definitions | + +### Dynamic Attribute Type + + + +Static attribute types should always be preferred over dynamic attribute types, when possible. + +Developers dealing with dynamic attribute data will need to have extensive knowledge of the [Terraform type system](/terraform/language/expressions/types) to properly handle all potential practitioner configuration scenarios. + +Refer to [Dynamic Data - Considerations](/terraform/plugin/framework/handling-data/dynamic-data#considerations) for more information. + + + +Attribute type that can be any value type, determined by Terraform or the provider at runtime. + +| Attribute Type | Use Case | +|----------------|----------| +| [Dynamic](/terraform/plugin/framework/handling-data/attributes/dynamic) | Any value type of data, determined at runtime. | \ No newline at end of file diff --git a/website/docs/plugin/framework/handling-data/dynamic-data.mdx b/website/docs/plugin/framework/handling-data/dynamic-data.mdx new file mode 100644 index 000000000..0f0ce2064 --- /dev/null +++ b/website/docs/plugin/framework/handling-data/dynamic-data.mdx @@ -0,0 +1,222 @@ +--- +page_title: 'Plugin Development - Framework: Handling Data - Dynamic Data' +description: >- + How to handle data when utilizing dynamic types. +--- + +# Dynamic Data + + + +Static types should always be preferred over dynamic types, when possible. + + + +Dynamic data handling uses the [framework dynamic type](/terraform/plugin/framework/handling-data/types/dynamic) to communicate to Terraform that the value type of a specific field will be determined at runtime. This allows a provider developer to handle multiple value types of data with a single attribute, parameter, or return. + +Dynamic data can be defined with: +- [Dynamic attribute](/terraform/plugin/framework/handling-data/attributes/dynamic) +- A [dynamic](/terraform/plugin/framework/handling-data/types/dynamic) attribute type in an [object attribute](/terraform/plugin/framework/handling-data/attributes/object) +- [Dynamic function parameter](/terraform/plugin/framework/functions/parameters/dynamic) +- [Dynamic function return](/terraform/plugin/framework/functions/returns/dynamic) +- A [dynamic](/terraform/plugin/framework/handling-data/types/dynamic) attribute type in an [object parameter](/terraform/plugin/framework/functions/parameters/object) +- A [dynamic](/terraform/plugin/framework/handling-data/types/dynamic) attribute type in an [object return](/terraform/plugin/framework/functions/returns/object) + +Using dynamic data has a negative impact on practitioner experience when using Terraform and downstream tooling, like practitioner configuration editor integrations. Dynamics do not change how [Terraform's static type system](/terraform/language/expressions/types) behaves and all data consistency rules are applied the same as static types. Provider developers should understand all the below [considerations](#considerations) when creating a provider with a dynamic type. + +Only use a dynamic type when there is not a suitable static type alternative. + +## Considerations + +When dynamic data is used, Terraform will no longer have any static information about the value types expected for a given attribute, function parameter, or function return. This results in behaviors that the provider developer will need to account for with additional documentation, code, error messaging, etc. + +### Downstream Tooling + +Practitioner configuration editor integrations, like the Terraform VSCode extension and language server, cannot provide any static information when using dynamic data in configurations. This can result in practitioners using dynamic data in expressions (like [`for`](/terraform/language/expressions/for)) incorrectly that will only error at runtime. + +Given this example, a resource schema defines a top level computed [dynamic attribute](/terraform/plugin/framework/handling-data/attributes/dynamic) named `example_attribute`: + +```go +func (r ThingResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attribute": schema.DynamicAttribute{ + Computed: true, + // ... potentially other fields ... + }, + // ... potentially other attributes ... + }, + } +} +``` + +The configuration below would be valid until a practitioner runs an apply. If the type of `example_attribute` is not iterable, then the practitioner will receive an error only when they run a command: + +```hcl +resource "examplecloud_thing" "example" {} + +output "dynamic_output" { + value = [for val in examplecloud_thing.example.example_attribute : val] +} +``` + +Results in the following error: + +```bash +│ Error: Iteration over non-iterable value +│ +│ on resource.tf line 15, in output "dynamic_output": +│ 15: value = [for val in examplecloud_thing.example.example_attribute : val] +│ ├──────────────── +│ │ examplecloud_thing.example.example_attribute is "string value" +│ +│ A value of type string cannot be used as the collection in a 'for' expression. +``` + +Dynamic data that is meant for practitioners to utilize in configurations should document all potential output types and expected usage to avoid confusing errors. + +### Handling All Possible Types + +Terraform will not [automatically convert](/terraform/language/expressions/types#type-conversion) values to conform to a static type, exposing provider developers to the Terraform type system directly. Provider developers will need to deal with this lack of type conversion by writing logic that handles [every possible type](/terraform/language/expressions/types#types) that Terraform supports. + +In this example, a resource schema defines a top level required [dynamic attribute](/terraform/plugin/framework/handling-data/attributes/dynamic) named `example_attribute`: + +```go +func (r ThingResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attribute": schema.DynamicAttribute{ + Required: true, + // ... potentially other fields ... + }, + // ... potentially other attributes ... + }, + } +} +``` + +An example of handling every possible Terraform type that could be provided to a configuration would be: + +```go + // Example data model definition + // type ExampleModel struct { + // ExampleAttribute types.Dynamic `tfsdk:"example_attribute"` + // } + switch value := data.ExampleAttribute.UnderlyingValue().(type) { + case types.Bool: + // Handle boolean value + case types.Number: + // Handle float64, int64, and number values + case types.List: + // Handle list value + case types.Map: + // Handle map value + case types.Object: + // Handle object value + case types.Set: + // Handle set value + case types.String: + // Handle string value + case types.Tuple: + // Handle tuple value + } +``` + +When writing test configurations and debugging provider issues, developers will also want to understand how Terraform represents [complex type literals](/terraform/language/expressions/type-constraints#complex-type-literals). For example, Terraform does not provide any way to directly represent lists, maps, or sets. + + +### Handling Underlying Null and Unknown Values + +With dynamic data, in addition to typical [null](/terraform/plugin/framework/handling-data/terraform-concepts#null-values) and [unknown](/terraform/plugin/framework/handling-data/terraform-concepts#unknown-values) value handling, provider developers will need to implement additional logic to determine if an underlying value for a dynamic is null or unknown. + +#### Underlying Null + +In the configuration below, Terraform knows the underlying value type, `string`, but the underlying string value is null: + +```hcl +resource "examplecloud_thing" "example" { + example_attribute = var.null_string +} + +variable "null_string" { + type = string + default = null +} +``` + +This will result in a known dynamic value, with an underlying value that is a null [string type](/terraform/plugin/framework/handling-data/types/string). This can be detected utilizing the [`(types.Dynamic).IsUnderlyingValueNull()`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/types/basetypes#DynamicValue.IsUnderlyingValueNull) method. An equivalent framework value to this scenario would be: + +```go +dynValWithNullString := types.DynamicValue(types.StringNull()) +``` + +#### Underlying Unknown + +In the configuration below, Terraform knows the underlying value type of [`random_shuffle.result`](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/shuffle#result), a `list(string)`, but the underlying list value is unknown: + +```hcl +resource "random_shuffle" "example" { + input = ["one", "two"] + result_count = 2 +} + +resource "examplecloud_thing" "this" { + example_attribute = random_shuffle.example.result +} +``` + +This will result in a known dynamic value, with an underlying value that is an unknown [list of string types](/terraform/plugin/framework/handling-data/types/list). This can be detected utilizing the [`(types.Dynamic).IsUnderlyingValueUnknown()`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/types/basetypes#DynamicValue.IsUnderlyingValueUnknown) method. An equivalent framework value to this scenario would be: + +```go +dynValWithUnknownList := types.DynamicValue(types.ListUnknown(types.StringType)) +``` + +### Understanding Type Consistency + +For [managed resources](/terraform/plugin/framework/resources), Terraform core implements [data consistency rules](https://github.com/hashicorp/terraform/blob/main/docs/resource-instance-change-lifecycle.md) between configuration, plan, and state data. With [dynamic attributes](/terraform/plugin/framework/handling-data/attributes/dynamic), these consistency rules are also applied to the **type** of data. + +For example, given a dynamic `example_attribute` that is computed and optional: + +```go +func (r ThingResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attribute": schema.DynamicAttribute{ + Computed: true, + Optional: true, + // ... potentially other fields ... + }, + // ... potentially other attributes ... + }, + } +} +``` + +If a practitioner configures this resource as: + +```hcl +resource "examplecloud_thing" "example" { + # This literal expression is a tuple[string, string] + example_attribute = ["one", "two"] +} +``` + +Then the exact type must be planned and stored in state during `apply` as a [tuple](/terraform/plugin/framework/handling-data/types/tuple) with two [string](/terraform/plugin/framework/handling-data/types/string) element types. If provider code attempts to store this attribute as a different type, like a [list](/terraform/plugin/framework/handling-data/types/list) of strings, even with the same data values, Terraform will produce an error during apply: + +```bash +│ Error: Provider produced inconsistent result after apply +│ +│ When applying changes to examplecloud_thing.example, provider "provider[\"TYPE\"]" produced an unexpected new value: .example_attribute: wrong final value type: tuple required. +│ +│ This is a bug in the provider, which should be reported in the providers own issue tracker. +``` + +If a practitioner configures this same resource as: + +```hcl +resource "examplecloud_thing" "example" { + example_attribute = tolist(["one", "two"]) +} +``` + +Then the exact type must be planned and stored in state during `apply` as a [list](/terraform/plugin/framework/handling-data/types/list) of strings. diff --git a/website/docs/plugin/framework/handling-data/paths.mdx b/website/docs/plugin/framework/handling-data/paths.mdx index 1460301b6..831db78ef 100644 --- a/website/docs/plugin/framework/handling-data/paths.mdx +++ b/website/docs/plugin/framework/handling-data/paths.mdx @@ -85,17 +85,18 @@ This pattern can be extended to as many calls as necessary. The different framew The following table shows the different [`path.Path` type](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/path#Path) methods associated with building paths for attribute implementations. Attribute types that cannot be traversed further are shown with N/A (not applicable). -| Attribute Type | Child Path Method | -| ------------------------- | ----------------- | -| `schema.BoolAttribute` | N/A | -| `schema.Float64Attribute` | N/A | -| `schema.Int64Attribute` | N/A | -| `schema.ListAttribute` | `AtListIndex()` | -| `schema.MapAttribute` | `AtMapKey()` | -| `schema.NumberAttribute` | N/A | -| `schema.ObjectAttribute` | `AtName()` | -| `schema.SetAttribute` | `AtSetValue()` | -| `schema.StringAttribute` | N/A | +| Attribute Type | Child Path Method | +|---------------------------|--------------------------------------------------------------------------------------------------------| +| `schema.BoolAttribute` | N/A | +| `schema.DynamicAttribute` | N/A | +| `schema.Float64Attribute` | N/A | +| `schema.Int64Attribute` | N/A | +| `schema.ListAttribute` | `AtListIndex()` | +| `schema.MapAttribute` | `AtMapKey()` | +| `schema.NumberAttribute` | N/A | +| `schema.ObjectAttribute` | `AtName()` | +| `schema.SetAttribute` | `AtSetValue()` | +| `schema.StringAttribute` | N/A | Given following schema example: @@ -529,3 +530,25 @@ The path which matches the `nested_block_string_attribute` string value in the o ```go path.Root("root_single_block").AtName("nested_single_block").AtName("nested_block_string_attribute") ``` + +### Building Dynamic Attribute Paths + +An attribute that implements `schema.DynamicAttribute` does not have a statically defined type, as the underlying value type is determined at runtime. When building paths for dynamic values, always target the root dynamic attribute. + +Given the following schema example: + +```go +schema.Schema{ + Attributes: map[string]schema.Attribute{ + "root_dynamic_attribute": schema.DynamicAttribute{ + Required: true, + }, + }, +} +``` + +The path which matches the dynamic value associated with the `root_dynamic_attribute` attribute is: + +```go +path.Root("root_dynamic_attribute") +``` diff --git a/website/docs/plugin/framework/handling-data/types/bool.mdx b/website/docs/plugin/framework/handling-data/types/bool.mdx index 0d4205b45..aed82e4e9 100644 --- a/website/docs/plugin/framework/handling-data/types/bool.mdx +++ b/website/docs/plugin/framework/handling-data/types/bool.mdx @@ -8,7 +8,7 @@ description: >- Bool types store a boolean true or false value. -By default, strings from [schema](/terraform/plugin/framework/handling-data/schemas) (configuration, plan, and state) data are represented in the framework by [`types.BoolType`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/types#BoolType) and its associated value storage type of [`types.Bool`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/types#Bool). These types fully support Terraform's [type system concepts](/terraform/plugin/framework/handling-data/terraform-concepts) that cannot be represented in Go built-in types, such as `*bool`. Framework types can be [extended](#extending) by provider code or shared libraries to provide specific use case functionality. +By default, booleans from [schema](/terraform/plugin/framework/handling-data/schemas) (configuration, plan, and state) data are represented in the framework by [`types.BoolType`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/types#BoolType) and its associated value storage type of [`types.Bool`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/types#Bool). These types fully support Terraform's [type system concepts](/terraform/plugin/framework/handling-data/terraform-concepts) that cannot be represented in Go built-in types, such as `*bool`. Framework types can be [extended](#extending) by provider code or shared libraries to provide specific use case functionality. ## Schema Definitions @@ -87,7 +87,7 @@ Otherwise, for certain framework functionality that does not require `types` imp * [`types.ObjectValueFrom()`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/types#ObjectValueFrom) * [`types.SetValueFrom()`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/types#SetValueFrom) -A Go built-in `bool`, `*bool` (only with typed `nil`, `(*bool)(nil)`), or type alias of `bool` such as `type MyStringType bool` can be used instead. +A Go built-in `bool`, `*bool` (only with typed `nil`, `(*bool)(nil)`), or type alias of `bool` such as `type MyBoolType bool` can be used instead. In this example, a `bool` is directly used to set a bool attribute value: diff --git a/website/docs/plugin/framework/handling-data/types/custom.mdx b/website/docs/plugin/framework/handling-data/types/custom.mdx index 3718ac399..7a7b8e879 100644 --- a/website/docs/plugin/framework/handling-data/types/custom.mdx +++ b/website/docs/plugin/framework/handling-data/types/custom.mdx @@ -90,6 +90,7 @@ The commonly used `types` package types are aliases to the `basetypes` package t | Framework Schema Type | Custom Schema Type Interface | | --- | --- | | [`basetypes.BoolType`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/types/basetypes#BoolType) | [`basetypes.BoolTypable`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/types/basetypes#BoolTypable) | +| [`basetypes.DynamicType`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/types/basetypes#DynamicType) | [`basetypes.DynamicTypable`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/types/basetypes#DynamicTypable) | | [`basetypes.Float64Type`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/types/basetypes#Float64Type) | [`basetypes.Float64Typable`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/types/basetypes#Float64Typable) | | [`basetypes.Int64Type`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/types/basetypes#Int64Type) | [`basetypes.Int64Typable`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/types/basetypes#Int64Typable) | | [`basetypes.ListType`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/types/basetypes#ListType) | [`basetypes.ListTypable`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/types/basetypes#ListTypable) | @@ -193,6 +194,7 @@ The commonly used `types` package types are aliases to the `basetypes` package t | Framework Schema Type | Custom Schema Type Interface | | --- | --- | | [`basetypes.BoolValue`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/types/basetypes#BoolValue) | [`basetypes.BoolValuable`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/types/basetypes#BoolValuable) | +| [`basetypes.DynamicValue`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/types/basetypes#DynamicValue) | [`basetypes.DynamicValuable`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/types/basetypes#DynamicValuable) | | [`basetypes.Float64Value`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/types/basetypes#Float64Value) | [`basetypes.Float64Valuable`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/types/basetypes#Float64Valuable) | | [`basetypes.Int64Value`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/types/basetypes#Int64Value) | [`basetypes.Int64Valuable`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/types/basetypes#Int64Valuable) | | [`basetypes.ListValue`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/types/basetypes#ListValue) | [`basetypes.ListValuable`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/types/basetypes#ListValuable) | diff --git a/website/docs/plugin/framework/handling-data/types/dynamic.mdx b/website/docs/plugin/framework/handling-data/types/dynamic.mdx new file mode 100644 index 000000000..f647f3789 --- /dev/null +++ b/website/docs/plugin/framework/handling-data/types/dynamic.mdx @@ -0,0 +1,154 @@ +--- +page_title: 'Plugin Development - Framework: Dynamic Type' +description: >- + Learn the dynamic value type in the provider development framework. +--- + +# Dynamic Type + + + +Static types should always be preferred over dynamic types, when possible. + +Developers dealing with dynamic data will need to have extensive knowledge of the [Terraform type system](/terraform/language/expressions/types) to properly handle all potential practitioner configuration scenarios. + +Refer to [Dynamic Data - Considerations](/terraform/plugin/framework/handling-data/dynamic-data#considerations) for more information. + + + +Dynamic is a container type that can have an underlying value of **any** type. + +By default, dynamic values from [schema](/terraform/plugin/framework/handling-data/schemas) (configuration, plan, and state) data are represented in the framework by [`types.DynamicType`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/types#DynamicType) and its associated value storage type of [`types.Dynamic`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/types#Dynamic). These types fully support Terraform's [type system concepts](/terraform/plugin/framework/handling-data/terraform-concepts) that cannot be represented in Go built-in types. Framework types can be [extended](#extending) by provider code or shared libraries to provide specific use case functionality. + +## Schema Definitions + +Use one of the following attribute types to directly add a dynamic value to a [schema](/terraform/plugin/framework/handling-data/schemas) or [nested attribute type](/terraform/plugin/framework/handling-data/attributes#nested-attribute-types): + +| Schema Type | Attribute Type | +|-------------|----------------| +| [Data Source](/terraform/plugin/framework/data-sources) | [`schema.DynamicAttribute`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/datasource/schema#DynamicAttribute) | +| [Provider](/terraform/plugin/framework/provider) | [`schema.DynamicAttribute`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/provider/schema#DynamicAttribute) | +| [Resource](/terraform/plugin/framework/resources) | [`schema.DynamicAttribute`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/resource/schema#DynamicAttribute) | + +Dynamic values are not supported as the element type of a [collection type](/terraform/plugin/framework/handling-data/types#collection-types) or within [collection attribute types](/terraform/plugin/framework/handling-data/attributes#collection-attribute-types). + +If the dynamic value should be a value type of an [object attribute type](/terraform/plugin/framework/handling-data/attributes#object-attribute-type), set the `AttrTypes` map value to `types.DynamicType` or the appropriate [custom type](#extending). + +## Accessing Values + + + +Review the [attribute documentation](/terraform/plugin/framework/handling-data/attributes/dynamic#accessing-values) to understand how schema-based data gets mapped into accessible values, such as a `types.Dynamic` in this case. + + + +Access `types.Dynamic` information via the following methods: + +* [`(types.Dynamic).IsNull() bool`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/types/basetypes#DynamicValue.IsNull): Returns true if the dynamic value is null. +* [`(types.Dynamic).IsUnderlyingValueNull() bool`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/types/basetypes#DynamicValue.IsUnderlyingValueNull): Returns true if the dynamic value is known but the underlying value is null. See the [Dynamic Data section](/terraform/plugin/framework/handling-data/dynamic-data#handling-underlying-null-and-unknown-values) for more information about null underlying values. +* [`(types.Dynamic).IsUnknown() bool`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/types/basetypes#DynamicValue.IsUnknown): Returns true if the dynamic value is unknown. +* [`(types.Dynamic).IsUnderlyingValueUnknown() bool`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/types/basetypes#DynamicValue.IsUnderlyingValueUnknown): Returns true if the dynamic value is known but the underlying value is unknown. See the [Dynamic Data section](/terraform/plugin/framework/handling-data/dynamic-data#handling-underlying-null-and-unknown-values) for more information about unknown underlying values. +* [`(types.Dynamic).UnderlyingValue() attr.Value`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/types/basetypes#DynamicValue.UnderlyingValue): Returns the underlying value of the dynamic container, will be `nil` if null or unknown. + +In this example, a dynamic value is checked for being null or unknown value first, before accessing its known value: + +```go +// Example data model definition +// type ExampleModel struct { +// ExampleAttribute types.Dynamic `tfsdk:"example_attribute"` +// } +// +// This would be filled in, such as calling: req.Plan.Get(ctx, &data) +var data ExampleModel + +// optional logic for handling null value +if data.ExampleAttribute.IsNull() { + // ... +} + +// optional logic for handling unknown value +if data.ExampleAttribute.IsUnknown() { + // ... +} + +// myDynamicVal now contains the underlying value, determined by Terraform at runtime +myDynamicVal := data.ExampleAttribute.UnderlyingValue() +``` + +### Handling the Underlying Value + +If a dynamic value is known, a [Go type switch](https://go.dev/tour/methods/16) can be used to access the type-specific methods for data handling: + +```go + switch value := data.ExampleAttribute.UnderlyingValue().(type) { + case types.Bool: + // Handle boolean value + case types.Number: + // Handle float64, int64, and number values + case types.List: + // Handle list value + case types.Map: + // Handle map value + case types.Object: + // Handle object value + case types.Set: + // Handle set value + case types.String: + // Handle string value + case types.Tuple: + // Handle tuple value + } +``` + + + +[Float64](/terraform/plugin/framework/handling-data/types/float64) and [Int64](/terraform/plugin/framework/handling-data/types/int64) framework types will never appear in the underlying value as both are represented as the Terraform type [`number`](/terraform/language/expressions/types#number). + + + +The type of the underlying value is determined at runtime by Terraform if the value is from configuration. Developers dealing with dynamic data will need to have extensive knowledge of the [Terraform type system](/terraform/language/expressions/types) to properly handle all potential practitioner configuration scenarios. + +Refer to the [Dynamic Data](/terraform/plugin/framework/handling-data/dynamic-data) documentation for more information. + +## Setting Values + +Call one of the following to create a `types.Dynamic` value: + +* [`types.DynamicNull()`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/types#DynamicNull): A null dynamic value. +* [`types.DynamicUnknown()`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/types#DynamicUnknown): An unknown dynamic value where the final static type is not known. Use `types.DynamicValue()` with an unknown value if the final static type is known. +* [`types.DynamicValue(attr.Value)`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/types#DynamicValue): A known dynamic value, with an underlying value determined by the `attr.Value` input. + +In this example, a known dynamic value is created, where the underlying value is a known string value: + +```go +types.DynamicValue(types.StringValue("hello world!")) +``` + +In this example, a known dynamic value is created, where the underlying value is a known object value: + +```go +elementTypes := map[string]attr.Type{ + "attr1": types.StringType, + "attr2": types.Int64Type, +} +elements := map[string]attr.Value{ + "attr1": types.StringValue("value"), + "attr2": types.Int64Value(123), +} +objectValue, diags := types.ObjectValue(elementTypes, elements) +// ... handle any diagnostics ... + +types.DynamicValue(objectValue) +``` + +There are no reflection rules defined for creating dynamic values, meaning they must be created using the `types` implementation. + +In this example, a `types.Dynamic` with a known boolean value is used to set a dynamic attribute value: + +```go +diags := resp.State.SetAttribute(ctx, path.Root("example_attribute"), types.DynamicValue(types.BoolValue(true))) +``` + +## Extending + +The framework supports extending its base type implementations with [custom types](/terraform/plugin/framework/handling-data/types/custom). These can adjust expected provider code usage depending on their implementation. diff --git a/website/docs/plugin/framework/handling-data/types/index.mdx b/website/docs/plugin/framework/handling-data/types/index.mdx index 0972cb408..ebe453013 100644 --- a/website/docs/plugin/framework/handling-data/types/index.mdx +++ b/website/docs/plugin/framework/handling-data/types/index.mdx @@ -16,6 +16,8 @@ The framework type system supports the following types: - [Primitive](#primitive-types): Type that contains a single value, such as a boolean, number, or string. - [Collection](#collection-types): Type that contains multiple values of a single element type, such as a list, map, or set. - [Object](#object-type): Type that defines a mapping of explicit attribute names to value types. +- [Tuple](#tuple-type): Type that defines an ordered collection of elements where each element has it's own type. +- [Dynamic](#dynamic-type): Container type that can contain any underlying value type. ### Primitive Types @@ -66,9 +68,19 @@ This type is associated with: Type that defines an ordered collection of elements where each element has it's own type. + This type intentionally includes less functionality than other types in the type system as it has limited real world application and therefore is not exposed to provider developers except when working with dynamic values. + | Type | Use Case | |----------------|----------| | [Tuple](/terraform/plugin/framework/handling-data/types/tuple) | Ordered collection of multiple element types | + +### Dynamic Type + +Container type that can contain any underlying value type, determined by Terraform or the provider at runtime. + +| Type | Use Case | +|----------------|----------| +| [Dynamic](/terraform/plugin/framework/handling-data/types/dynamic) | Any value type of data, determined at runtime. | \ No newline at end of file diff --git a/website/docs/plugin/framework/handling-data/types/tuple.mdx b/website/docs/plugin/framework/handling-data/types/tuple.mdx index 85a59dfcf..c05b603ca 100644 --- a/website/docs/plugin/framework/handling-data/types/tuple.mdx +++ b/website/docs/plugin/framework/handling-data/types/tuple.mdx @@ -5,7 +5,9 @@ description: >- --- + The tuple type doesn't have associated schema attributes as it has limited real world application. Provider developers will only encounter tuples when handling provider-defined function variadic parameters or dynamic values. + # Tuple Type diff --git a/website/docs/plugin/framework/resources/default.mdx b/website/docs/plugin/framework/resources/default.mdx index 3768a08c7..d543c7d2d 100644 --- a/website/docs/plugin/framework/resources/default.mdx +++ b/website/docs/plugin/framework/resources/default.mdx @@ -49,6 +49,7 @@ The framework implements static value defaults in the typed packages under `reso | Schema Type | Built-In Default Functions | |---|---| | [`schema.BoolAttribute`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/resource/schema#BoolAttribute) | [`resource/schema/booldefault` package](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault) | +| [`schema.DynamicAttribute`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/resource/schema#DynamicAttribute) | [`resource/schema/dynamicdefault` package](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/resource/schema/dynamicdefault) | | [`schema.Float64Attribute`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/resource/schema#Float64Attribute) | [`resource/schema/float64default` package](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/resource/schema/float64default) | | [`schema.Int64Attribute`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/resource/schema#Int64Attribute) | [`resource/schema/int64default` package](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/resource/schema/int64default) | | [`schema.ListAttribute`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/resource/schema#ListAttribute) / [`schema.ListNestedAttribute`](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/resource/schema#ListNestedAttribute) | [`resource/schema/listdefault` package](https://pkg.go.dev/github.com/hashicorp/terraform-plugin-framework/resource/schema/listdefault) | From 5c1f5750d508c0b4791a8a263cd94df54225d3b5 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Wed, 20 Mar 2024 17:51:17 -0400 Subject: [PATCH 10/12] delete duplicate tests and add name params --- function/definition_test.go | 127 ++++++++++++------------------------ 1 file changed, 40 insertions(+), 87 deletions(-) diff --git a/function/definition_test.go b/function/definition_test.go index a0266fb89..d2f75b99f 100644 --- a/function/definition_test.go +++ b/function/definition_test.go @@ -243,101 +243,52 @@ func TestDefinitionValidateImplementation(t *testing.T) { definition: function.Definition{ Parameters: []function.Parameter{ function.MapParameter{ + Name: "map_with_dynamic", ElementType: types.DynamicType, }, }, Return: function.StringReturn{}, }, - expected: diag.Diagnostics{ - diag.NewErrorDiagnostic( - "Invalid Function Definition", - "When validating the function definition, an implementation issue was found. "+ - "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ - "Parameter \"param1\" at position 0 contains a collection type with a nested dynamic type.\n\n"+ - "Dynamic types inside of collections are not currently supported in terraform-plugin-framework. "+ - "If underlying dynamic values are required, replace the \"param1\" parameter definition with DynamicParameter instead.", - ), - }, - }, - "variadic-param-dynamic-in-collection": { - definition: function.Definition{ - Parameters: []function.Parameter{ - function.StringParameter{}, - function.StringParameter{}, - }, - VariadicParameter: function.SetParameter{ - ElementType: types.DynamicType, - }, - Return: function.StringReturn{}, - }, - expected: diag.Diagnostics{ - diag.NewErrorDiagnostic( - "Invalid Function Definition", - "When validating the function definition, an implementation issue was found. "+ - "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ - "Variadic parameter \"varparam\" contains a collection type with a nested dynamic type.\n\n"+ - "Dynamic types inside of collections are not currently supported in terraform-plugin-framework. "+ - "If underlying dynamic values are required, replace the variadic parameter definition with DynamicParameter instead.", - ), - }, - }, - "return-dynamic-in-collection": { - definition: function.Definition{ - Return: function.ListReturn{ - ElementType: types.DynamicType, - }, - }, - expected: diag.Diagnostics{ - diag.NewErrorDiagnostic( - "Invalid Function Definition", - "When validating the function definition, an implementation issue was found. "+ - "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ - "Return contains a collection type with a nested dynamic type.\n\n"+ - "Dynamic types inside of collections are not currently supported in terraform-plugin-framework. "+ - "If underlying dynamic values are required, replace the return definition with DynamicReturn instead.", - ), - }, - }, - "param-dynamic-in-collection": { - definition: function.Definition{ - Parameters: []function.Parameter{ - function.MapParameter{ - ElementType: types.DynamicType, - }, + expected: function.DefinitionValidateResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Function Definition", + "When validating the function definition, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "Parameter \"map_with_dynamic\" at position 0 contains a collection type with a nested dynamic type.\n\n"+ + "Dynamic types inside of collections are not currently supported in terraform-plugin-framework. "+ + "If underlying dynamic values are required, replace the \"map_with_dynamic\" parameter definition with DynamicParameter instead.", + ), }, - Return: function.StringReturn{}, - }, - expected: diag.Diagnostics{ - diag.NewErrorDiagnostic( - "Invalid Function Definition", - "When validating the function definition, an implementation issue was found. "+ - "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ - "Parameter \"param1\" at position 0 contains a collection type with a nested dynamic type.\n\n"+ - "Dynamic types inside of collections are not currently supported in terraform-plugin-framework. "+ - "If underlying dynamic values are required, replace the \"param1\" parameter definition with DynamicParameter instead.", - ), }, }, "variadic-param-dynamic-in-collection": { definition: function.Definition{ Parameters: []function.Parameter{ - function.StringParameter{}, - function.StringParameter{}, + function.StringParameter{ + Name: "string_param1", + }, + function.StringParameter{ + Name: "string_param2", + }, }, VariadicParameter: function.SetParameter{ + Name: "set_with_dynamic", ElementType: types.DynamicType, }, Return: function.StringReturn{}, }, - expected: diag.Diagnostics{ - diag.NewErrorDiagnostic( - "Invalid Function Definition", - "When validating the function definition, an implementation issue was found. "+ - "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ - "Variadic parameter \"varparam\" contains a collection type with a nested dynamic type.\n\n"+ - "Dynamic types inside of collections are not currently supported in terraform-plugin-framework. "+ - "If underlying dynamic values are required, replace the variadic parameter definition with DynamicParameter instead.", - ), + expected: function.DefinitionValidateResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Function Definition", + "When validating the function definition, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "Variadic parameter \"set_with_dynamic\" contains a collection type with a nested dynamic type.\n\n"+ + "Dynamic types inside of collections are not currently supported in terraform-plugin-framework. "+ + "If underlying dynamic values are required, replace the variadic parameter definition with DynamicParameter instead.", + ), + }, }, }, "return-dynamic-in-collection": { @@ -346,15 +297,17 @@ func TestDefinitionValidateImplementation(t *testing.T) { ElementType: types.DynamicType, }, }, - expected: diag.Diagnostics{ - diag.NewErrorDiagnostic( - "Invalid Function Definition", - "When validating the function definition, an implementation issue was found. "+ - "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ - "Return contains a collection type with a nested dynamic type.\n\n"+ - "Dynamic types inside of collections are not currently supported in terraform-plugin-framework. "+ - "If underlying dynamic values are required, replace the return definition with DynamicReturn instead.", - ), + expected: function.DefinitionValidateResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid Function Definition", + "When validating the function definition, an implementation issue was found. "+ + "This is always an issue with the provider and should be reported to the provider developers.\n\n"+ + "Return contains a collection type with a nested dynamic type.\n\n"+ + "Dynamic types inside of collections are not currently supported in terraform-plugin-framework. "+ + "If underlying dynamic values are required, replace the return definition with DynamicReturn instead.", + ), + }, }, }, "conflicting-param-names": { From 9b2199b99c6572789e46f3a9b58feb1b4ba07ab9 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Wed, 20 Mar 2024 17:57:48 -0400 Subject: [PATCH 11/12] remove duplicate from merge conflict --- function/definition.go | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/function/definition.go b/function/definition.go index 425c69bbf..87dd45fb2 100644 --- a/function/definition.go +++ b/function/definition.go @@ -142,18 +142,6 @@ func (d Definition) ValidateImplementation(ctx context.Context, req DefinitionVa diags.Append(resp.Diagnostics...) } - if paramWithValidateImplementation, ok := param.(fwfunction.ParameterWithValidateImplementation); ok { - req := fwfunction.ValidateParameterImplementationRequest{ - Name: name, - ParameterPosition: ¶meterPosition, - } - resp := &fwfunction.ValidateParameterImplementationResponse{} - - paramWithValidateImplementation.ValidateImplementation(ctx, req, resp) - - diags.Append(resp.Diagnostics...) - } - conflictPos, exists := paramNames[name] if exists && name != "" { diags.AddError( @@ -192,17 +180,6 @@ func (d Definition) ValidateImplementation(ctx context.Context, req DefinitionVa diags.Append(resp.Diagnostics...) } - if paramWithValidateImplementation, ok := d.VariadicParameter.(fwfunction.ParameterWithValidateImplementation); ok { - req := fwfunction.ValidateParameterImplementationRequest{ - Name: name, - } - resp := &fwfunction.ValidateParameterImplementationResponse{} - - paramWithValidateImplementation.ValidateImplementation(ctx, req, resp) - - diags.Append(resp.Diagnostics...) - } - conflictPos, exists := paramNames[name] if exists && name != "" { diags.AddError( From a2f04e215f2480b8b9998d70de94bcd94f5a4289 Mon Sep 17 00:00:00 2001 From: Benjamin Bennett Date: Thu, 21 Mar 2024 06:53:49 +0000 Subject: [PATCH 12/12] Apply suggestions from code review Co-authored-by: Brian Flad --- website/docs/plugin/framework/functions/implementation.mdx | 4 ++-- website/docs/plugin/framework/functions/parameters/index.mdx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/website/docs/plugin/framework/functions/implementation.mdx b/website/docs/plugin/framework/functions/implementation.mdx index 79ffb1a51..98c87e1f5 100644 --- a/website/docs/plugin/framework/functions/implementation.mdx +++ b/website/docs/plugin/framework/functions/implementation.mdx @@ -224,7 +224,7 @@ func (f *ExampleFunction) Definition(ctx context.Context, req function.Definitio }, VariadicParameter: function.StringParameter{ Name: "variadic_param", - // ... other fields ... + // ... other fields ... }, } } @@ -260,7 +260,7 @@ func (f *ExampleFunction) Definition(ctx context.Context, req function.Definitio VariadicParameter: function.StringParameter{ Name: "variadic_param", // ... other fields ... - }, + }, } } diff --git a/website/docs/plugin/framework/functions/parameters/index.mdx b/website/docs/plugin/framework/functions/parameters/index.mdx index 9150b532d..e0f23042c 100644 --- a/website/docs/plugin/framework/functions/parameters/index.mdx +++ b/website/docs/plugin/framework/functions/parameters/index.mdx @@ -70,7 +70,7 @@ All parameter types have a `Name` field that is **required**. Attempting to use unnamed parameters will generate runtime errors of the following form: -```shell +```text │ Error: Failed to load plugin schemas │ │ Error while loading schemas for plugin components: Failed to obtain provider schema: Could not load the schema for provider registry.terraform.io/cloud_provider/cloud_resource: failed to @@ -84,7 +84,7 @@ Attempting to use unnamed parameters will generate runtime errors of the followi Parameter names are used in runtime errors to highlight which parameter is causing the issue. For example, using a value that is incompatible with the parameter type will generate an error message such as the following: -```shell +```text │ Error: Invalid function argument │ │ on resource.tf line 10, in resource "example_resource" "example":