From b8932ee30a1d7e1363442f615d0320af99495604 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Fri, 21 Nov 2025 15:19:28 -0500 Subject: [PATCH 1/5] non-number primitives --- .../use_non_null_state_for_unknown.go | 54 ++++++ .../use_non_null_state_for_unknown_test.go | 166 ++++++++++++++++++ .../boolplanmodifier/use_state_for_unknown.go | 4 + .../use_non_null_state_for_unknown.go | 54 ++++++ .../use_non_null_state_for_unknown_test.go | 166 ++++++++++++++++++ .../use_state_for_unknown.go | 4 + .../use_non_null_state_for_unknown.go | 54 ++++++ .../use_non_null_state_for_unknown_test.go | 166 ++++++++++++++++++ .../use_state_for_unknown.go | 4 + 9 files changed, 672 insertions(+) create mode 100644 resource/schema/boolplanmodifier/use_non_null_state_for_unknown.go create mode 100644 resource/schema/boolplanmodifier/use_non_null_state_for_unknown_test.go create mode 100644 resource/schema/dynamicplanmodifier/use_non_null_state_for_unknown.go create mode 100644 resource/schema/dynamicplanmodifier/use_non_null_state_for_unknown_test.go create mode 100644 resource/schema/stringplanmodifier/use_non_null_state_for_unknown.go create mode 100644 resource/schema/stringplanmodifier/use_non_null_state_for_unknown_test.go diff --git a/resource/schema/boolplanmodifier/use_non_null_state_for_unknown.go b/resource/schema/boolplanmodifier/use_non_null_state_for_unknown.go new file mode 100644 index 000000000..776628f16 --- /dev/null +++ b/resource/schema/boolplanmodifier/use_non_null_state_for_unknown.go @@ -0,0 +1,54 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package boolplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// UseNonNullStateForUnknown returns a plan modifier that copies a known, non-null, prior state +// value into the planned value. Use this when it is known that an unconfigured value will remain the +// same after the attribute is updated to a non-null value. +// +// 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 non-null prior state value in the +// plan, unless a prior plan modifier adjusts the value. +// +// This plan modifier can be a useful alternative to [UseStateForUnknown] when the attribute is +// a child of a nested attribute that can be null after the resource is created. +func UseNonNullStateForUnknown() planmodifier.Bool { + return useNonNullStateForUnknown{} +} + +type useNonNullStateForUnknown struct{} + +func (m useNonNullStateForUnknown) Description(_ context.Context) string { + return "Once set to a non-null value, the value of this attribute in state will not change." +} + +func (m useNonNullStateForUnknown) MarkdownDescription(_ context.Context) string { + return "Once set to a non-null value, the value of this attribute in state will not change." +} + +func (m useNonNullStateForUnknown) PlanModifyBool(ctx context.Context, req planmodifier.BoolRequest, resp *planmodifier.BoolResponse) { + // Do nothing if the state value is null. + if req.StateValue.IsNull() { + return + } + + // Do nothing if there is a known planned value. + if !req.PlanValue.IsUnknown() { + return + } + + // Do nothing if there is an unknown configuration value, otherwise interpolation gets messed up. + if req.ConfigValue.IsUnknown() { + return + } + + resp.PlanValue = req.StateValue +} diff --git a/resource/schema/boolplanmodifier/use_non_null_state_for_unknown_test.go b/resource/schema/boolplanmodifier/use_non_null_state_for_unknown_test.go new file mode 100644 index 000000000..cbcc733fa --- /dev/null +++ b/resource/schema/boolplanmodifier/use_non_null_state_for_unknown_test.go @@ -0,0 +1,166 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package boolplanmodifier_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier" + "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 TestUseNonNullStateForUnknownModifierPlanModifyBool(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + request planmodifier.BoolRequest + expected *planmodifier.BoolResponse + }{ + "null-state": { + // when we first create the resource, the state value will be null, + // so use the unknown value + request: planmodifier.BoolRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Bool, + }, + }, + nil, + ), + }, + StateValue: types.BoolNull(), + PlanValue: types.BoolUnknown(), + ConfigValue: types.BoolNull(), + }, + expected: &planmodifier.BoolResponse{ + PlanValue: types.BoolUnknown(), + }, + }, + "known-plan": { + // this would really only happen if we had a plan + // modifier setting the value before this plan modifier + // got to it + // + // but we still want to preserve that value, in this + // case + request: planmodifier.BoolRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Bool, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Bool, false), + }, + ), + }, + StateValue: types.BoolValue(false), + PlanValue: types.BoolValue(true), + ConfigValue: types.BoolNull(), + }, + expected: &planmodifier.BoolResponse{ + PlanValue: types.BoolValue(true), + }, + }, + "non-null-state-value-unknown-plan": { + // this is the situation we want to preserve the state in + request: planmodifier.BoolRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Bool, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Bool, true), + }, + ), + }, + StateValue: types.BoolValue(true), + PlanValue: types.BoolUnknown(), + ConfigValue: types.BoolNull(), + }, + expected: &planmodifier.BoolResponse{ + PlanValue: types.BoolValue(true), + }, + }, + "null-state-value-unknown-plan": { + // Null state values should not be preserved + request: planmodifier.BoolRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Bool, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Bool, nil), + }, + ), + }, + StateValue: types.BoolNull(), + PlanValue: types.BoolUnknown(), + ConfigValue: types.BoolNull(), + }, + expected: &planmodifier.BoolResponse{ + PlanValue: types.BoolUnknown(), + }, + }, + "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.BoolRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Bool, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Bool, true), + }, + ), + }, + StateValue: types.BoolValue(true), + PlanValue: types.BoolUnknown(), + ConfigValue: types.BoolUnknown(), + }, + expected: &planmodifier.BoolResponse{ + PlanValue: types.BoolUnknown(), + }, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.BoolResponse{ + PlanValue: testCase.request.PlanValue, + } + + boolplanmodifier.UseNonNullStateForUnknown().PlanModifyBool(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/boolplanmodifier/use_state_for_unknown.go b/resource/schema/boolplanmodifier/use_state_for_unknown.go index 693303e77..6fcdb013d 100644 --- a/resource/schema/boolplanmodifier/use_state_for_unknown.go +++ b/resource/schema/boolplanmodifier/use_state_for_unknown.go @@ -17,6 +17,10 @@ import ( // 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. +// +// Null is also a known value in Terraform and will be copied to the planned value +// by this plan modifier. For use-cases like a child attribute of a nested attribute or +// if null is desired to be marked as unknown in the case of an update, use [UseNonNullStateForUnknown]. func UseStateForUnknown() planmodifier.Bool { return useStateForUnknownModifier{} } diff --git a/resource/schema/dynamicplanmodifier/use_non_null_state_for_unknown.go b/resource/schema/dynamicplanmodifier/use_non_null_state_for_unknown.go new file mode 100644 index 000000000..3d5b76f75 --- /dev/null +++ b/resource/schema/dynamicplanmodifier/use_non_null_state_for_unknown.go @@ -0,0 +1,54 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package dynamicplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// UseNonNullStateForUnknown returns a plan modifier that copies a known, non-null, prior state +// value into the planned value. Use this when it is known that an unconfigured value will remain the +// same after the attribute is updated to a non-null value. +// +// 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 non-null prior state value in the +// plan, unless a prior plan modifier adjusts the value. +// +// This plan modifier can be a useful alternative to [UseStateForUnknown] when the attribute is +// a child of a nested attribute that can be null after the resource is created. +func UseNonNullStateForUnknown() planmodifier.Dynamic { + return useNonNullStateForUnknown{} +} + +type useNonNullStateForUnknown struct{} + +func (m useNonNullStateForUnknown) Description(_ context.Context) string { + return "Once set to a non-null value, the value of this attribute in state will not change." +} + +func (m useNonNullStateForUnknown) MarkdownDescription(_ context.Context) string { + return "Once set to a non-null value, the value of this attribute in state will not change." +} + +func (m useNonNullStateForUnknown) PlanModifyDynamic(ctx context.Context, req planmodifier.DynamicRequest, resp *planmodifier.DynamicResponse) { + // Do nothing if the state value is null. + if req.StateValue.IsNull() { + return + } + + // Do nothing if there is a known planned value. + if !req.PlanValue.IsUnknown() { + return + } + + // Do nothing if there is an unknown configuration value, otherwise interpolation gets messed up. + if req.ConfigValue.IsUnknown() { + return + } + + resp.PlanValue = req.StateValue +} diff --git a/resource/schema/dynamicplanmodifier/use_non_null_state_for_unknown_test.go b/resource/schema/dynamicplanmodifier/use_non_null_state_for_unknown_test.go new file mode 100644 index 000000000..4bf580765 --- /dev/null +++ b/resource/schema/dynamicplanmodifier/use_non_null_state_for_unknown_test.go @@ -0,0 +1,166 @@ +// 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/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestUseNonNullStateForUnknownModifierPlanModifyDynamic(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + request planmodifier.DynamicRequest + expected *planmodifier.DynamicResponse + }{ + "null-state": { + // when we first create the resource, the state value will be null, + // so use the unknown value + request: planmodifier.DynamicRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.DynamicPseudoType, + }, + }, + nil, + ), + }, + StateValue: types.DynamicNull(), + 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 + // + // but we still want to preserve that value, in this + // case + request: planmodifier.DynamicRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.DynamicPseudoType, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.String, "other"), + }, + ), + }, + StateValue: types.DynamicValue(types.StringValue("other")), + PlanValue: types.DynamicValue(types.StringValue("test")), + ConfigValue: types.DynamicNull(), + }, + expected: &planmodifier.DynamicResponse{ + PlanValue: types.DynamicValue(types.StringValue("test")), + }, + }, + "non-null-state-value-unknown-plan": { + // this is the situation we want to preserve the state in + request: planmodifier.DynamicRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.DynamicPseudoType, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.String, "test"), + }, + ), + }, + StateValue: types.DynamicValue(types.StringValue("test")), + PlanValue: types.DynamicUnknown(), + ConfigValue: types.DynamicNull(), + }, + expected: &planmodifier.DynamicResponse{ + PlanValue: types.DynamicValue(types.StringValue("test")), + }, + }, + "null-state-value-unknown-plan": { + // Null state values should not be preserved + request: planmodifier.DynamicRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.DynamicPseudoType, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.DynamicPseudoType, nil), + }, + ), + }, + StateValue: types.DynamicNull(), + PlanValue: types.DynamicUnknown(), + ConfigValue: types.DynamicNull(), + }, + expected: &planmodifier.DynamicResponse{ + PlanValue: types.DynamicUnknown(), + }, + }, + "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{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.DynamicPseudoType, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.String, "test"), + }, + ), + }, + StateValue: types.DynamicValue(types.StringValue("test")), + PlanValue: types.DynamicUnknown(), + ConfigValue: types.DynamicUnknown(), + }, + expected: &planmodifier.DynamicResponse{ + PlanValue: types.DynamicUnknown(), + }, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.DynamicResponse{ + PlanValue: testCase.request.PlanValue, + } + + dynamicplanmodifier.UseNonNullStateForUnknown().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 index 4a11f3ffa..704a1ba9c 100644 --- a/resource/schema/dynamicplanmodifier/use_state_for_unknown.go +++ b/resource/schema/dynamicplanmodifier/use_state_for_unknown.go @@ -17,6 +17,10 @@ import ( // 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. +// +// Null is also a known value in Terraform and will be copied to the planned value +// by this plan modifier. For use-cases like a child attribute of a nested attribute or +// if null is desired to be marked as unknown in the case of an update, use [UseNonNullStateForUnknown]. func UseStateForUnknown() planmodifier.Dynamic { return useStateForUnknownModifier{} } diff --git a/resource/schema/stringplanmodifier/use_non_null_state_for_unknown.go b/resource/schema/stringplanmodifier/use_non_null_state_for_unknown.go new file mode 100644 index 000000000..c6850deca --- /dev/null +++ b/resource/schema/stringplanmodifier/use_non_null_state_for_unknown.go @@ -0,0 +1,54 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package stringplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// UseNonNullStateForUnknown returns a plan modifier that copies a known, non-null, prior state +// value into the planned value. Use this when it is known that an unconfigured value will remain the +// same after the attribute is updated to a non-null value. +// +// 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 non-null prior state value in the +// plan, unless a prior plan modifier adjusts the value. +// +// This plan modifier can be a useful alternative to [UseStateForUnknown] when the attribute is +// a child of a nested attribute that can be null after the resource is created. +func UseNonNullStateForUnknown() planmodifier.String { + return useNonNullStateForUnknown{} +} + +type useNonNullStateForUnknown struct{} + +func (m useNonNullStateForUnknown) Description(_ context.Context) string { + return "Once set to a non-null value, the value of this attribute in state will not change." +} + +func (m useNonNullStateForUnknown) MarkdownDescription(_ context.Context) string { + return "Once set to a non-null value, the value of this attribute in state will not change." +} + +func (m useNonNullStateForUnknown) PlanModifyString(ctx context.Context, req planmodifier.StringRequest, resp *planmodifier.StringResponse) { + // Do nothing if the state value is null. + if req.StateValue.IsNull() { + return + } + + // Do nothing if there is a known planned value. + if !req.PlanValue.IsUnknown() { + return + } + + // Do nothing if there is an unknown configuration value, otherwise interpolation gets messed up. + if req.ConfigValue.IsUnknown() { + return + } + + resp.PlanValue = req.StateValue +} diff --git a/resource/schema/stringplanmodifier/use_non_null_state_for_unknown_test.go b/resource/schema/stringplanmodifier/use_non_null_state_for_unknown_test.go new file mode 100644 index 000000000..d78e88ca8 --- /dev/null +++ b/resource/schema/stringplanmodifier/use_non_null_state_for_unknown_test.go @@ -0,0 +1,166 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package stringplanmodifier_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestUseNonNullStateForUnknownModifierPlanModifyString(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + request planmodifier.StringRequest + expected *planmodifier.StringResponse + }{ + "null-state": { + // when we first create the resource, the state value will be null, + // so use the unknown value + request: planmodifier.StringRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.String, + }, + }, + nil, + ), + }, + StateValue: types.StringNull(), + PlanValue: types.StringUnknown(), + ConfigValue: types.StringNull(), + }, + expected: &planmodifier.StringResponse{ + PlanValue: types.StringUnknown(), + }, + }, + "known-plan": { + // this would really only happen if we had a plan + // modifier setting the value before this plan modifier + // got to it + // + // but we still want to preserve that value, in this + // case + request: planmodifier.StringRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.String, "other"), + }, + ), + }, + StateValue: types.StringValue("other"), + PlanValue: types.StringValue("test"), + ConfigValue: types.StringNull(), + }, + expected: &planmodifier.StringResponse{ + PlanValue: types.StringValue("test"), + }, + }, + "non-null-state-value-unknown-plan": { + // this is the situation we want to preserve the state in + request: planmodifier.StringRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.String, "test"), + }, + ), + }, + StateValue: types.StringValue("test"), + PlanValue: types.StringUnknown(), + ConfigValue: types.StringNull(), + }, + expected: &planmodifier.StringResponse{ + PlanValue: types.StringValue("test"), + }, + }, + "null-state-value-unknown-plan": { + // Null state values should not be preserved + request: planmodifier.StringRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.String, nil), + }, + ), + }, + StateValue: types.StringNull(), + PlanValue: types.StringUnknown(), + ConfigValue: types.StringNull(), + }, + expected: &planmodifier.StringResponse{ + PlanValue: types.StringUnknown(), + }, + }, + "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.StringRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.String, "test"), + }, + ), + }, + StateValue: types.StringValue("test"), + PlanValue: types.StringUnknown(), + ConfigValue: types.StringUnknown(), + }, + expected: &planmodifier.StringResponse{ + PlanValue: types.StringUnknown(), + }, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.StringResponse{ + PlanValue: testCase.request.PlanValue, + } + + stringplanmodifier.UseNonNullStateForUnknown().PlanModifyString(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/stringplanmodifier/use_state_for_unknown.go b/resource/schema/stringplanmodifier/use_state_for_unknown.go index a6d77962e..45ffff92b 100644 --- a/resource/schema/stringplanmodifier/use_state_for_unknown.go +++ b/resource/schema/stringplanmodifier/use_state_for_unknown.go @@ -17,6 +17,10 @@ import ( // 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. +// +// Null is also a known value in Terraform and will be copied to the planned value +// by this plan modifier. For use-cases like a child attribute of a nested attribute or +// if null is desired to be marked as unknown in the case of an update, use [UseNonNullStateForUnknown]. func UseStateForUnknown() planmodifier.String { return useStateForUnknownModifier{} } From a3fcf67ccf98d91f129f13e3e1c016f54a097ad4 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Fri, 21 Nov 2025 16:24:24 -0500 Subject: [PATCH 2/5] number primitive plan modifiers --- .../use_non_null_state_for_unknown.go | 54 ++++++ .../use_non_null_state_for_unknown_test.go | 166 +++++++++++++++++ .../use_state_for_unknown.go | 4 + .../use_non_null_state_for_unknown.go | 54 ++++++ .../use_non_null_state_for_unknown_test.go | 166 +++++++++++++++++ .../use_state_for_unknown.go | 4 + .../use_non_null_state_for_unknown.go | 54 ++++++ .../use_non_null_state_for_unknown_test.go | 166 +++++++++++++++++ .../use_state_for_unknown.go | 4 + .../use_non_null_state_for_unknown.go | 54 ++++++ .../use_non_null_state_for_unknown_test.go | 166 +++++++++++++++++ .../use_state_for_unknown.go | 4 + .../use_non_null_state_for_unknown.go | 54 ++++++ .../use_non_null_state_for_unknown_test.go | 167 ++++++++++++++++++ .../use_state_for_unknown.go | 4 + 15 files changed, 1121 insertions(+) create mode 100644 resource/schema/float32planmodifier/use_non_null_state_for_unknown.go create mode 100644 resource/schema/float32planmodifier/use_non_null_state_for_unknown_test.go create mode 100644 resource/schema/float64planmodifier/use_non_null_state_for_unknown.go create mode 100644 resource/schema/float64planmodifier/use_non_null_state_for_unknown_test.go create mode 100644 resource/schema/int32planmodifier/use_non_null_state_for_unknown.go create mode 100644 resource/schema/int32planmodifier/use_non_null_state_for_unknown_test.go create mode 100644 resource/schema/int64planmodifier/use_non_null_state_for_unknown.go create mode 100644 resource/schema/int64planmodifier/use_non_null_state_for_unknown_test.go create mode 100644 resource/schema/numberplanmodifier/use_non_null_state_for_unknown.go create mode 100644 resource/schema/numberplanmodifier/use_non_null_state_for_unknown_test.go diff --git a/resource/schema/float32planmodifier/use_non_null_state_for_unknown.go b/resource/schema/float32planmodifier/use_non_null_state_for_unknown.go new file mode 100644 index 000000000..fcbd56790 --- /dev/null +++ b/resource/schema/float32planmodifier/use_non_null_state_for_unknown.go @@ -0,0 +1,54 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package float32planmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// UseNonNullStateForUnknown returns a plan modifier that copies a known, non-null, prior state +// value into the planned value. Use this when it is known that an unconfigured value will remain the +// same after the attribute is updated to a non-null value. +// +// 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 non-null prior state value in the +// plan, unless a prior plan modifier adjusts the value. +// +// This plan modifier can be a useful alternative to [UseStateForUnknown] when the attribute is +// a child of a nested attribute that can be null after the resource is created. +func UseNonNullStateForUnknown() planmodifier.Float32 { + return useNonNullStateForUnknown{} +} + +type useNonNullStateForUnknown struct{} + +func (m useNonNullStateForUnknown) Description(_ context.Context) string { + return "Once set to a non-null value, the value of this attribute in state will not change." +} + +func (m useNonNullStateForUnknown) MarkdownDescription(_ context.Context) string { + return "Once set to a non-null value, the value of this attribute in state will not change." +} + +func (m useNonNullStateForUnknown) PlanModifyFloat32(ctx context.Context, req planmodifier.Float32Request, resp *planmodifier.Float32Response) { + // Do nothing if the state value is null. + if req.StateValue.IsNull() { + return + } + + // Do nothing if there is a known planned value. + if !req.PlanValue.IsUnknown() { + return + } + + // Do nothing if there is an unknown configuration value, otherwise interpolation gets messed up. + if req.ConfigValue.IsUnknown() { + return + } + + resp.PlanValue = req.StateValue +} diff --git a/resource/schema/float32planmodifier/use_non_null_state_for_unknown_test.go b/resource/schema/float32planmodifier/use_non_null_state_for_unknown_test.go new file mode 100644 index 000000000..d13b01ce3 --- /dev/null +++ b/resource/schema/float32planmodifier/use_non_null_state_for_unknown_test.go @@ -0,0 +1,166 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package float32planmodifier_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/float32planmodifier" + "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 TestUseNonNullStateForUnknownModifierPlanModifyFloat32(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + request planmodifier.Float32Request + expected *planmodifier.Float32Response + }{ + "null-state": { + // when we first create the resource, the state value will be null, + // so use the unknown value + request: planmodifier.Float32Request{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Number, + }, + }, + nil, + ), + }, + StateValue: types.Float32Null(), + PlanValue: types.Float32Unknown(), + ConfigValue: types.Float32Null(), + }, + expected: &planmodifier.Float32Response{ + PlanValue: types.Float32Unknown(), + }, + }, + "known-plan": { + // this would really only happen if we had a plan + // modifier setting the value before this plan modifier + // got to it + // + // but we still want to preserve that value, in this + // case + request: planmodifier.Float32Request{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Number, 1.2), + }, + ), + }, + StateValue: types.Float32Value(1.2), + PlanValue: types.Float32Value(2.4), + ConfigValue: types.Float32Null(), + }, + expected: &planmodifier.Float32Response{ + PlanValue: types.Float32Value(2.4), + }, + }, + "non-null-state-value-unknown-plan": { + // this is the situation we want to preserve the state in + request: planmodifier.Float32Request{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Number, 2.4), + }, + ), + }, + StateValue: types.Float32Value(2.4), + PlanValue: types.Float32Unknown(), + ConfigValue: types.Float32Null(), + }, + expected: &planmodifier.Float32Response{ + PlanValue: types.Float32Value(2.4), + }, + }, + "null-state-value-unknown-plan": { + // Null state values should not be preserved + request: planmodifier.Float32Request{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Number, nil), + }, + ), + }, + StateValue: types.Float32Null(), + PlanValue: types.Float32Unknown(), + ConfigValue: types.Float32Null(), + }, + expected: &planmodifier.Float32Response{ + PlanValue: types.Float32Unknown(), + }, + }, + "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.Float32Request{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Number, 2.4), + }, + ), + }, + StateValue: types.Float32Value(2.4), + PlanValue: types.Float32Unknown(), + ConfigValue: types.Float32Unknown(), + }, + expected: &planmodifier.Float32Response{ + PlanValue: types.Float32Unknown(), + }, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.Float32Response{ + PlanValue: testCase.request.PlanValue, + } + + float32planmodifier.UseNonNullStateForUnknown().PlanModifyFloat32(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/float32planmodifier/use_state_for_unknown.go b/resource/schema/float32planmodifier/use_state_for_unknown.go index 81d5ac09d..c92192b5b 100644 --- a/resource/schema/float32planmodifier/use_state_for_unknown.go +++ b/resource/schema/float32planmodifier/use_state_for_unknown.go @@ -17,6 +17,10 @@ import ( // 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. +// +// Null is also a known value in Terraform and will be copied to the planned value +// by this plan modifier. For use-cases like a child attribute of a nested attribute or +// if null is desired to be marked as unknown in the case of an update, use [UseNonNullStateForUnknown]. func UseStateForUnknown() planmodifier.Float32 { return useStateForUnknownModifier{} } diff --git a/resource/schema/float64planmodifier/use_non_null_state_for_unknown.go b/resource/schema/float64planmodifier/use_non_null_state_for_unknown.go new file mode 100644 index 000000000..e3f0c7c3d --- /dev/null +++ b/resource/schema/float64planmodifier/use_non_null_state_for_unknown.go @@ -0,0 +1,54 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package float64planmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// UseNonNullStateForUnknown returns a plan modifier that copies a known, non-null, prior state +// value into the planned value. Use this when it is known that an unconfigured value will remain the +// same after the attribute is updated to a non-null value. +// +// 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 non-null prior state value in the +// plan, unless a prior plan modifier adjusts the value. +// +// This plan modifier can be a useful alternative to [UseStateForUnknown] when the attribute is +// a child of a nested attribute that can be null after the resource is created. +func UseNonNullStateForUnknown() planmodifier.Float64 { + return useNonNullStateForUnknown{} +} + +type useNonNullStateForUnknown struct{} + +func (m useNonNullStateForUnknown) Description(_ context.Context) string { + return "Once set to a non-null value, the value of this attribute in state will not change." +} + +func (m useNonNullStateForUnknown) MarkdownDescription(_ context.Context) string { + return "Once set to a non-null value, the value of this attribute in state will not change." +} + +func (m useNonNullStateForUnknown) PlanModifyFloat64(ctx context.Context, req planmodifier.Float64Request, resp *planmodifier.Float64Response) { + // Do nothing if the state value is null. + if req.StateValue.IsNull() { + return + } + + // Do nothing if there is a known planned value. + if !req.PlanValue.IsUnknown() { + return + } + + // Do nothing if there is an unknown configuration value, otherwise interpolation gets messed up. + if req.ConfigValue.IsUnknown() { + return + } + + resp.PlanValue = req.StateValue +} diff --git a/resource/schema/float64planmodifier/use_non_null_state_for_unknown_test.go b/resource/schema/float64planmodifier/use_non_null_state_for_unknown_test.go new file mode 100644 index 000000000..65fbce5ff --- /dev/null +++ b/resource/schema/float64planmodifier/use_non_null_state_for_unknown_test.go @@ -0,0 +1,166 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package float64planmodifier_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/float64planmodifier" + "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 TestUseNonNullStateForUnknownModifierPlanModifyFloat64(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + request planmodifier.Float64Request + expected *planmodifier.Float64Response + }{ + "null-state": { + // when we first create the resource, the state value will be null, + // so use the unknown value + request: planmodifier.Float64Request{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Number, + }, + }, + nil, + ), + }, + StateValue: types.Float64Null(), + PlanValue: types.Float64Unknown(), + ConfigValue: types.Float64Null(), + }, + expected: &planmodifier.Float64Response{ + PlanValue: types.Float64Unknown(), + }, + }, + "known-plan": { + // this would really only happen if we had a plan + // modifier setting the value before this plan modifier + // got to it + // + // but we still want to preserve that value, in this + // case + request: planmodifier.Float64Request{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Number, 1.2), + }, + ), + }, + StateValue: types.Float64Value(1.2), + PlanValue: types.Float64Value(2.4), + ConfigValue: types.Float64Null(), + }, + expected: &planmodifier.Float64Response{ + PlanValue: types.Float64Value(2.4), + }, + }, + "non-null-state-value-unknown-plan": { + // this is the situation we want to preserve the state in + request: planmodifier.Float64Request{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Number, 2.4), + }, + ), + }, + StateValue: types.Float64Value(2.4), + PlanValue: types.Float64Unknown(), + ConfigValue: types.Float64Null(), + }, + expected: &planmodifier.Float64Response{ + PlanValue: types.Float64Value(2.4), + }, + }, + "null-state-value-unknown-plan": { + // Null state values should not be preserved + request: planmodifier.Float64Request{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Number, nil), + }, + ), + }, + StateValue: types.Float64Null(), + PlanValue: types.Float64Unknown(), + ConfigValue: types.Float64Null(), + }, + expected: &planmodifier.Float64Response{ + PlanValue: types.Float64Unknown(), + }, + }, + "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.Float64Request{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Number, 2.4), + }, + ), + }, + StateValue: types.Float64Value(2.4), + PlanValue: types.Float64Unknown(), + ConfigValue: types.Float64Unknown(), + }, + expected: &planmodifier.Float64Response{ + PlanValue: types.Float64Unknown(), + }, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.Float64Response{ + PlanValue: testCase.request.PlanValue, + } + + float64planmodifier.UseNonNullStateForUnknown().PlanModifyFloat64(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/float64planmodifier/use_state_for_unknown.go b/resource/schema/float64planmodifier/use_state_for_unknown.go index 2d2491fb1..921ad980f 100644 --- a/resource/schema/float64planmodifier/use_state_for_unknown.go +++ b/resource/schema/float64planmodifier/use_state_for_unknown.go @@ -17,6 +17,10 @@ import ( // 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. +// +// Null is also a known value in Terraform and will be copied to the planned value +// by this plan modifier. For use-cases like a child attribute of a nested attribute or +// if null is desired to be marked as unknown in the case of an update, use [UseNonNullStateForUnknown]. func UseStateForUnknown() planmodifier.Float64 { return useStateForUnknownModifier{} } diff --git a/resource/schema/int32planmodifier/use_non_null_state_for_unknown.go b/resource/schema/int32planmodifier/use_non_null_state_for_unknown.go new file mode 100644 index 000000000..d7a6c283c --- /dev/null +++ b/resource/schema/int32planmodifier/use_non_null_state_for_unknown.go @@ -0,0 +1,54 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int32planmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// UseNonNullStateForUnknown returns a plan modifier that copies a known, non-null, prior state +// value into the planned value. Use this when it is known that an unconfigured value will remain the +// same after the attribute is updated to a non-null value. +// +// 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 non-null prior state value in the +// plan, unless a prior plan modifier adjusts the value. +// +// This plan modifier can be a useful alternative to [UseStateForUnknown] when the attribute is +// a child of a nested attribute that can be null after the resource is created. +func UseNonNullStateForUnknown() planmodifier.Int32 { + return useNonNullStateForUnknown{} +} + +type useNonNullStateForUnknown struct{} + +func (m useNonNullStateForUnknown) Description(_ context.Context) string { + return "Once set to a non-null value, the value of this attribute in state will not change." +} + +func (m useNonNullStateForUnknown) MarkdownDescription(_ context.Context) string { + return "Once set to a non-null value, the value of this attribute in state will not change." +} + +func (m useNonNullStateForUnknown) PlanModifyInt32(ctx context.Context, req planmodifier.Int32Request, resp *planmodifier.Int32Response) { + // Do nothing if the state value is null. + if req.StateValue.IsNull() { + return + } + + // Do nothing if there is a known planned value. + if !req.PlanValue.IsUnknown() { + return + } + + // Do nothing if there is an unknown configuration value, otherwise interpolation gets messed up. + if req.ConfigValue.IsUnknown() { + return + } + + resp.PlanValue = req.StateValue +} diff --git a/resource/schema/int32planmodifier/use_non_null_state_for_unknown_test.go b/resource/schema/int32planmodifier/use_non_null_state_for_unknown_test.go new file mode 100644 index 000000000..442aac77f --- /dev/null +++ b/resource/schema/int32planmodifier/use_non_null_state_for_unknown_test.go @@ -0,0 +1,166 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int32planmodifier_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int32planmodifier" + "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 TestUseNonNullStateForUnknownModifierPlanModifyInt32(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + request planmodifier.Int32Request + expected *planmodifier.Int32Response + }{ + "null-state": { + // when we first create the resource, the state value will be null, + // so use the unknown value + request: planmodifier.Int32Request{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Number, + }, + }, + nil, + ), + }, + StateValue: types.Int32Null(), + PlanValue: types.Int32Unknown(), + ConfigValue: types.Int32Null(), + }, + expected: &planmodifier.Int32Response{ + PlanValue: types.Int32Unknown(), + }, + }, + "known-plan": { + // this would really only happen if we had a plan + // modifier setting the value before this plan modifier + // got to it + // + // but we still want to preserve that value, in this + // case + request: planmodifier.Int32Request{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Number, 1), + }, + ), + }, + StateValue: types.Int32Value(1), + PlanValue: types.Int32Value(2), + ConfigValue: types.Int32Null(), + }, + expected: &planmodifier.Int32Response{ + PlanValue: types.Int32Value(2), + }, + }, + "non-null-state-value-unknown-plan": { + // this is the situation we want to preserve the state in + request: planmodifier.Int32Request{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Number, 2), + }, + ), + }, + StateValue: types.Int32Value(2), + PlanValue: types.Int32Unknown(), + ConfigValue: types.Int32Null(), + }, + expected: &planmodifier.Int32Response{ + PlanValue: types.Int32Value(2), + }, + }, + "null-state-value-unknown-plan": { + // Null state values should not be preserved + request: planmodifier.Int32Request{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Number, nil), + }, + ), + }, + StateValue: types.Int32Null(), + PlanValue: types.Int32Unknown(), + ConfigValue: types.Int32Null(), + }, + expected: &planmodifier.Int32Response{ + PlanValue: types.Int32Unknown(), + }, + }, + "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.Int32Request{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Number, 2), + }, + ), + }, + StateValue: types.Int32Value(2), + PlanValue: types.Int32Unknown(), + ConfigValue: types.Int32Unknown(), + }, + expected: &planmodifier.Int32Response{ + PlanValue: types.Int32Unknown(), + }, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.Int32Response{ + PlanValue: testCase.request.PlanValue, + } + + int32planmodifier.UseNonNullStateForUnknown().PlanModifyInt32(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/int32planmodifier/use_state_for_unknown.go b/resource/schema/int32planmodifier/use_state_for_unknown.go index 0fb28639b..fd722b02c 100644 --- a/resource/schema/int32planmodifier/use_state_for_unknown.go +++ b/resource/schema/int32planmodifier/use_state_for_unknown.go @@ -17,6 +17,10 @@ import ( // 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. +// +// Null is also a known value in Terraform and will be copied to the planned value +// by this plan modifier. For use-cases like a child attribute of a nested attribute or +// if null is desired to be marked as unknown in the case of an update, use [UseNonNullStateForUnknown]. func UseStateForUnknown() planmodifier.Int32 { return useStateForUnknownModifier{} } diff --git a/resource/schema/int64planmodifier/use_non_null_state_for_unknown.go b/resource/schema/int64planmodifier/use_non_null_state_for_unknown.go new file mode 100644 index 000000000..1ff32f3eb --- /dev/null +++ b/resource/schema/int64planmodifier/use_non_null_state_for_unknown.go @@ -0,0 +1,54 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int64planmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// UseNonNullStateForUnknown returns a plan modifier that copies a known, non-null, prior state +// value into the planned value. Use this when it is known that an unconfigured value will remain the +// same after the attribute is updated to a non-null value. +// +// 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 non-null prior state value in the +// plan, unless a prior plan modifier adjusts the value. +// +// This plan modifier can be a useful alternative to [UseStateForUnknown] when the attribute is +// a child of a nested attribute that can be null after the resource is created. +func UseNonNullStateForUnknown() planmodifier.Int64 { + return useNonNullStateForUnknown{} +} + +type useNonNullStateForUnknown struct{} + +func (m useNonNullStateForUnknown) Description(_ context.Context) string { + return "Once set to a non-null value, the value of this attribute in state will not change." +} + +func (m useNonNullStateForUnknown) MarkdownDescription(_ context.Context) string { + return "Once set to a non-null value, the value of this attribute in state will not change." +} + +func (m useNonNullStateForUnknown) PlanModifyInt64(ctx context.Context, req planmodifier.Int64Request, resp *planmodifier.Int64Response) { + // Do nothing if the state value is null. + if req.StateValue.IsNull() { + return + } + + // Do nothing if there is a known planned value. + if !req.PlanValue.IsUnknown() { + return + } + + // Do nothing if there is an unknown configuration value, otherwise interpolation gets messed up. + if req.ConfigValue.IsUnknown() { + return + } + + resp.PlanValue = req.StateValue +} diff --git a/resource/schema/int64planmodifier/use_non_null_state_for_unknown_test.go b/resource/schema/int64planmodifier/use_non_null_state_for_unknown_test.go new file mode 100644 index 000000000..587897380 --- /dev/null +++ b/resource/schema/int64planmodifier/use_non_null_state_for_unknown_test.go @@ -0,0 +1,166 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int64planmodifier_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier" + "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 TestUseNonNullStateForUnknownModifierPlanModifyInt64(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + request planmodifier.Int64Request + expected *planmodifier.Int64Response + }{ + "null-state": { + // when we first create the resource, the state value will be null, + // so use the unknown value + request: planmodifier.Int64Request{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Number, + }, + }, + nil, + ), + }, + StateValue: types.Int64Null(), + PlanValue: types.Int64Unknown(), + ConfigValue: types.Int64Null(), + }, + expected: &planmodifier.Int64Response{ + PlanValue: types.Int64Unknown(), + }, + }, + "known-plan": { + // this would really only happen if we had a plan + // modifier setting the value before this plan modifier + // got to it + // + // but we still want to preserve that value, in this + // case + request: planmodifier.Int64Request{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Number, 1), + }, + ), + }, + StateValue: types.Int64Value(1), + PlanValue: types.Int64Value(2), + ConfigValue: types.Int64Null(), + }, + expected: &planmodifier.Int64Response{ + PlanValue: types.Int64Value(2), + }, + }, + "non-null-state-value-unknown-plan": { + // this is the situation we want to preserve the state in + request: planmodifier.Int64Request{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Number, 2), + }, + ), + }, + StateValue: types.Int64Value(2), + PlanValue: types.Int64Unknown(), + ConfigValue: types.Int64Null(), + }, + expected: &planmodifier.Int64Response{ + PlanValue: types.Int64Value(2), + }, + }, + "null-state-value-unknown-plan": { + // Null state values should not be preserved + request: planmodifier.Int64Request{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Number, nil), + }, + ), + }, + StateValue: types.Int64Null(), + PlanValue: types.Int64Unknown(), + ConfigValue: types.Int64Null(), + }, + expected: &planmodifier.Int64Response{ + PlanValue: types.Int64Unknown(), + }, + }, + "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.Int64Request{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Number, 2), + }, + ), + }, + StateValue: types.Int64Value(2), + PlanValue: types.Int64Unknown(), + ConfigValue: types.Int64Unknown(), + }, + expected: &planmodifier.Int64Response{ + PlanValue: types.Int64Unknown(), + }, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.Int64Response{ + PlanValue: testCase.request.PlanValue, + } + + int64planmodifier.UseNonNullStateForUnknown().PlanModifyInt64(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/int64planmodifier/use_state_for_unknown.go b/resource/schema/int64planmodifier/use_state_for_unknown.go index ae94f2663..8425641fe 100644 --- a/resource/schema/int64planmodifier/use_state_for_unknown.go +++ b/resource/schema/int64planmodifier/use_state_for_unknown.go @@ -17,6 +17,10 @@ import ( // 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. +// +// Null is also a known value in Terraform and will be copied to the planned value +// by this plan modifier. For use-cases like a child attribute of a nested attribute or +// if null is desired to be marked as unknown in the case of an update, use [UseNonNullStateForUnknown]. func UseStateForUnknown() planmodifier.Int64 { return useStateForUnknownModifier{} } diff --git a/resource/schema/numberplanmodifier/use_non_null_state_for_unknown.go b/resource/schema/numberplanmodifier/use_non_null_state_for_unknown.go new file mode 100644 index 000000000..6eb2d327f --- /dev/null +++ b/resource/schema/numberplanmodifier/use_non_null_state_for_unknown.go @@ -0,0 +1,54 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package numberplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// UseNonNullStateForUnknown returns a plan modifier that copies a known, non-null, prior state +// value into the planned value. Use this when it is known that an unconfigured value will remain the +// same after the attribute is updated to a non-null value. +// +// 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 non-null prior state value in the +// plan, unless a prior plan modifier adjusts the value. +// +// This plan modifier can be a useful alternative to [UseStateForUnknown] when the attribute is +// a child of a nested attribute that can be null after the resource is created. +func UseNonNullStateForUnknown() planmodifier.Number { + return useNonNullStateForUnknown{} +} + +type useNonNullStateForUnknown struct{} + +func (m useNonNullStateForUnknown) Description(_ context.Context) string { + return "Once set to a non-null value, the value of this attribute in state will not change." +} + +func (m useNonNullStateForUnknown) MarkdownDescription(_ context.Context) string { + return "Once set to a non-null value, the value of this attribute in state will not change." +} + +func (m useNonNullStateForUnknown) PlanModifyNumber(ctx context.Context, req planmodifier.NumberRequest, resp *planmodifier.NumberResponse) { + // Do nothing if the state value is null. + if req.StateValue.IsNull() { + return + } + + // Do nothing if there is a known planned value. + if !req.PlanValue.IsUnknown() { + return + } + + // Do nothing if there is an unknown configuration value, otherwise interpolation gets messed up. + if req.ConfigValue.IsUnknown() { + return + } + + resp.PlanValue = req.StateValue +} diff --git a/resource/schema/numberplanmodifier/use_non_null_state_for_unknown_test.go b/resource/schema/numberplanmodifier/use_non_null_state_for_unknown_test.go new file mode 100644 index 000000000..7b774fb75 --- /dev/null +++ b/resource/schema/numberplanmodifier/use_non_null_state_for_unknown_test.go @@ -0,0 +1,167 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package numberplanmodifier_test + +import ( + "context" + "math/big" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/numberplanmodifier" + "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 TestUseNonNullStateForUnknownModifierPlanModifyNumber(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + request planmodifier.NumberRequest + expected *planmodifier.NumberResponse + }{ + "null-state": { + // when we first create the resource, the state value will be null, + // so use the unknown value + request: planmodifier.NumberRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Number, + }, + }, + nil, + ), + }, + StateValue: types.NumberNull(), + PlanValue: types.NumberUnknown(), + ConfigValue: types.NumberNull(), + }, + expected: &planmodifier.NumberResponse{ + PlanValue: types.NumberUnknown(), + }, + }, + "known-plan": { + // this would really only happen if we had a plan + // modifier setting the value before this plan modifier + // got to it + // + // but we still want to preserve that value, in this + // case + request: planmodifier.NumberRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Number, 1.2), + }, + ), + }, + StateValue: types.NumberValue(big.NewFloat(1.2)), + PlanValue: types.NumberValue(big.NewFloat(2.4)), + ConfigValue: types.NumberNull(), + }, + expected: &planmodifier.NumberResponse{ + PlanValue: types.NumberValue(big.NewFloat(2.4)), + }, + }, + "non-null-state-value-unknown-plan": { + // this is the situation we want to preserve the state in + request: planmodifier.NumberRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Number, big.NewFloat(2.4)), + }, + ), + }, + StateValue: types.NumberValue(big.NewFloat(2.4)), + PlanValue: types.NumberUnknown(), + ConfigValue: types.NumberNull(), + }, + expected: &planmodifier.NumberResponse{ + PlanValue: types.NumberValue(big.NewFloat(2.4)), + }, + }, + "null-state-value-unknown-plan": { + // Null state values should not be preserved + request: planmodifier.NumberRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Number, nil), + }, + ), + }, + StateValue: types.NumberNull(), + PlanValue: types.NumberUnknown(), + ConfigValue: types.NumberNull(), + }, + expected: &planmodifier.NumberResponse{ + PlanValue: types.NumberUnknown(), + }, + }, + "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.NumberRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Number, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue(tftypes.Number, big.NewFloat(2.4)), + }, + ), + }, + StateValue: types.NumberValue(big.NewFloat(2.4)), + PlanValue: types.NumberUnknown(), + ConfigValue: types.NumberUnknown(), + }, + expected: &planmodifier.NumberResponse{ + PlanValue: types.NumberUnknown(), + }, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.NumberResponse{ + PlanValue: testCase.request.PlanValue, + } + + numberplanmodifier.UseNonNullStateForUnknown().PlanModifyNumber(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/numberplanmodifier/use_state_for_unknown.go b/resource/schema/numberplanmodifier/use_state_for_unknown.go index 94c79be5f..9d583d3b2 100644 --- a/resource/schema/numberplanmodifier/use_state_for_unknown.go +++ b/resource/schema/numberplanmodifier/use_state_for_unknown.go @@ -17,6 +17,10 @@ import ( // 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. +// +// Null is also a known value in Terraform and will be copied to the planned value +// by this plan modifier. For use-cases like a child attribute of a nested attribute or +// if null is desired to be marked as unknown in the case of an update, use [UseNonNullStateForUnknown]. func UseStateForUnknown() planmodifier.Number { return useStateForUnknownModifier{} } From 31ccfb07a5fc3bc3f5cb7959964524b75536c6f0 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Fri, 21 Nov 2025 16:46:16 -0500 Subject: [PATCH 3/5] collections + object --- .../use_non_null_state_for_unknown.go | 54 ++++++ .../use_non_null_state_for_unknown_test.go | 168 ++++++++++++++++++ .../listplanmodifier/use_state_for_unknown.go | 4 + .../use_non_null_state_for_unknown.go | 54 ++++++ .../use_non_null_state_for_unknown_test.go | 168 ++++++++++++++++++ .../mapplanmodifier/use_state_for_unknown.go | 4 + .../use_non_null_state_for_unknown.go | 54 ++++++ .../use_non_null_state_for_unknown_test.go | 168 ++++++++++++++++++ .../use_state_for_unknown.go | 4 + .../use_non_null_state_for_unknown.go | 54 ++++++ .../use_non_null_state_for_unknown_test.go | 168 ++++++++++++++++++ .../setplanmodifier/use_state_for_unknown.go | 4 + 12 files changed, 904 insertions(+) create mode 100644 resource/schema/listplanmodifier/use_non_null_state_for_unknown.go create mode 100644 resource/schema/listplanmodifier/use_non_null_state_for_unknown_test.go create mode 100644 resource/schema/mapplanmodifier/use_non_null_state_for_unknown.go create mode 100644 resource/schema/mapplanmodifier/use_non_null_state_for_unknown_test.go create mode 100644 resource/schema/objectplanmodifier/use_non_null_state_for_unknown.go create mode 100644 resource/schema/objectplanmodifier/use_non_null_state_for_unknown_test.go create mode 100644 resource/schema/setplanmodifier/use_non_null_state_for_unknown.go create mode 100644 resource/schema/setplanmodifier/use_non_null_state_for_unknown_test.go diff --git a/resource/schema/listplanmodifier/use_non_null_state_for_unknown.go b/resource/schema/listplanmodifier/use_non_null_state_for_unknown.go new file mode 100644 index 000000000..60b967438 --- /dev/null +++ b/resource/schema/listplanmodifier/use_non_null_state_for_unknown.go @@ -0,0 +1,54 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package listplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// UseNonNullStateForUnknown returns a plan modifier that copies a known, non-null, prior state +// value into the planned value. Use this when it is known that an unconfigured value will remain the +// same after the attribute is updated to a non-null value. +// +// 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 non-null prior state value in the +// plan, unless a prior plan modifier adjusts the value. +// +// This plan modifier can be a useful alternative to [UseStateForUnknown] when the attribute is +// a child of a nested attribute that can be null after the resource is created. +func UseNonNullStateForUnknown() planmodifier.List { + return useNonNullStateForUnknown{} +} + +type useNonNullStateForUnknown struct{} + +func (m useNonNullStateForUnknown) Description(_ context.Context) string { + return "Once set to a non-null value, the value of this attribute in state will not change." +} + +func (m useNonNullStateForUnknown) MarkdownDescription(_ context.Context) string { + return "Once set to a non-null value, the value of this attribute in state will not change." +} + +func (m useNonNullStateForUnknown) PlanModifyList(ctx context.Context, req planmodifier.ListRequest, resp *planmodifier.ListResponse) { + // Do nothing if the state value is null. + if req.StateValue.IsNull() { + return + } + + // Do nothing if there is a known planned value. + if !req.PlanValue.IsUnknown() { + return + } + + // Do nothing if there is an unknown configuration value, otherwise interpolation gets messed up. + if req.ConfigValue.IsUnknown() { + return + } + + resp.PlanValue = req.StateValue +} diff --git a/resource/schema/listplanmodifier/use_non_null_state_for_unknown_test.go b/resource/schema/listplanmodifier/use_non_null_state_for_unknown_test.go new file mode 100644 index 000000000..196af5b8d --- /dev/null +++ b/resource/schema/listplanmodifier/use_non_null_state_for_unknown_test.go @@ -0,0 +1,168 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package listplanmodifier_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/listplanmodifier" + "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 TestUseNonNullStateForUnknownModifierPlanModifyList(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + request planmodifier.ListRequest + expected *planmodifier.ListResponse + }{ + "null-state": { + // when we first create the resource, the state value will be null, + // so use the unknown value + request: planmodifier.ListRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.List{ElementType: tftypes.String}, + }, + }, + nil, + ), + }, + StateValue: types.ListNull(types.StringType), + PlanValue: types.ListUnknown(types.StringType), + ConfigValue: types.ListNull(types.StringType), + }, + expected: &planmodifier.ListResponse{ + PlanValue: types.ListUnknown(types.StringType), + }, + }, + "known-plan": { + // this would really only happen if we had a plan + // modifier setting the value before this plan modifier + // got to it + // + // but we still want to preserve that value, in this + // case + request: planmodifier.ListRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.List{ElementType: tftypes.String}, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue( + tftypes.List{ElementType: tftypes.String}, + []tftypes.Value{ + tftypes.NewValue(tftypes.String, "other"), + }, + ), + }, + ), + }, + StateValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("other")}), + PlanValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + ConfigValue: types.ListNull(types.StringType), + }, + expected: &planmodifier.ListResponse{ + PlanValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + }, + }, + "non-null-state-value-unknown-plan": { + // this is the situation we want to preserve the state in + request: planmodifier.ListRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.List{ElementType: tftypes.String}, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue( + tftypes.List{ElementType: tftypes.String}, + []tftypes.Value{ + tftypes.NewValue(tftypes.String, "test"), + }, + ), + }, + ), + }, + StateValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + PlanValue: types.ListUnknown(types.StringType), + ConfigValue: types.ListNull(types.StringType), + }, + expected: &planmodifier.ListResponse{ + PlanValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + }, + }, + "null-state-value-unknown-plan": { + // Null state values should not be preserved + request: planmodifier.ListRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.List{ElementType: tftypes.String}, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue( + tftypes.List{ElementType: tftypes.String}, + nil, + ), + }, + ), + }, + StateValue: types.ListNull(types.StringType), + PlanValue: types.ListUnknown(types.StringType), + ConfigValue: types.ListNull(types.StringType), + }, + expected: &planmodifier.ListResponse{ + PlanValue: types.ListUnknown(types.StringType), + }, + }, + "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.ListRequest{ + StateValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + PlanValue: types.ListUnknown(types.StringType), + ConfigValue: types.ListUnknown(types.StringType), + }, + expected: &planmodifier.ListResponse{ + PlanValue: types.ListUnknown(types.StringType), + }, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.ListResponse{ + PlanValue: testCase.request.PlanValue, + } + + listplanmodifier.UseNonNullStateForUnknown().PlanModifyList(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/listplanmodifier/use_state_for_unknown.go b/resource/schema/listplanmodifier/use_state_for_unknown.go index d71b6717f..2c1646314 100644 --- a/resource/schema/listplanmodifier/use_state_for_unknown.go +++ b/resource/schema/listplanmodifier/use_state_for_unknown.go @@ -17,6 +17,10 @@ import ( // 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. +// +// Null is also a known value in Terraform and will be copied to the planned value +// by this plan modifier. For use-cases like a child attribute of a nested attribute or +// if null is desired to be marked as unknown in the case of an update, use [UseNonNullStateForUnknown]. func UseStateForUnknown() planmodifier.List { return useStateForUnknownModifier{} } diff --git a/resource/schema/mapplanmodifier/use_non_null_state_for_unknown.go b/resource/schema/mapplanmodifier/use_non_null_state_for_unknown.go new file mode 100644 index 000000000..e08fc64a0 --- /dev/null +++ b/resource/schema/mapplanmodifier/use_non_null_state_for_unknown.go @@ -0,0 +1,54 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package mapplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// UseNonNullStateForUnknown returns a plan modifier that copies a known, non-null, prior state +// value into the planned value. Use this when it is known that an unconfigured value will remain the +// same after the attribute is updated to a non-null value. +// +// 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 non-null prior state value in the +// plan, unless a prior plan modifier adjusts the value. +// +// This plan modifier can be a useful alternative to [UseStateForUnknown] when the attribute is +// a child of a nested attribute that can be null after the resource is created. +func UseNonNullStateForUnknown() planmodifier.Map { + return useNonNullStateForUnknown{} +} + +type useNonNullStateForUnknown struct{} + +func (m useNonNullStateForUnknown) Description(_ context.Context) string { + return "Once set to a non-null value, the value of this attribute in state will not change." +} + +func (m useNonNullStateForUnknown) MarkdownDescription(_ context.Context) string { + return "Once set to a non-null value, the value of this attribute in state will not change." +} + +func (m useNonNullStateForUnknown) PlanModifyMap(ctx context.Context, req planmodifier.MapRequest, resp *planmodifier.MapResponse) { + // Do nothing if the state value is null. + if req.StateValue.IsNull() { + return + } + + // Do nothing if there is a known planned value. + if !req.PlanValue.IsUnknown() { + return + } + + // Do nothing if there is an unknown configuration value, otherwise interpolation gets messed up. + if req.ConfigValue.IsUnknown() { + return + } + + resp.PlanValue = req.StateValue +} diff --git a/resource/schema/mapplanmodifier/use_non_null_state_for_unknown_test.go b/resource/schema/mapplanmodifier/use_non_null_state_for_unknown_test.go new file mode 100644 index 000000000..600dec7d3 --- /dev/null +++ b/resource/schema/mapplanmodifier/use_non_null_state_for_unknown_test.go @@ -0,0 +1,168 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package mapplanmodifier_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/mapplanmodifier" + "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 TestUseNonNullStateForUnknownModifierPlanModifyMap(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + request planmodifier.MapRequest + expected *planmodifier.MapResponse + }{ + "null-state": { + // when we first create the resource, the state value will be null, + // so use the unknown value + request: planmodifier.MapRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Map{ElementType: tftypes.String}, + }, + }, + nil, + ), + }, + StateValue: types.MapNull(types.StringType), + PlanValue: types.MapUnknown(types.StringType), + ConfigValue: types.MapNull(types.StringType), + }, + expected: &planmodifier.MapResponse{ + PlanValue: types.MapUnknown(types.StringType), + }, + }, + "known-plan": { + // this would really only happen if we had a plan + // modifier setting the value before this plan modifier + // got to it + // + // but we still want to preserve that value, in this + // case + request: planmodifier.MapRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Map{ElementType: tftypes.String}, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue( + tftypes.Map{ElementType: tftypes.String}, + map[string]tftypes.Value{ + "testkey": tftypes.NewValue(tftypes.String, "other"), + }, + ), + }, + ), + }, + StateValue: types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("other")}), + PlanValue: types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("test")}), + ConfigValue: types.MapNull(types.StringType), + }, + expected: &planmodifier.MapResponse{ + PlanValue: types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("test")}), + }, + }, + "non-null-state-value-unknown-plan": { + // this is the situation we want to preserve the state in + request: planmodifier.MapRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Map{ElementType: tftypes.String}, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue( + tftypes.Map{ElementType: tftypes.String}, + map[string]tftypes.Value{ + "testkey": tftypes.NewValue(tftypes.String, "test"), + }, + ), + }, + ), + }, + StateValue: types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("test")}), + PlanValue: types.MapUnknown(types.StringType), + ConfigValue: types.MapNull(types.StringType), + }, + expected: &planmodifier.MapResponse{ + PlanValue: types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("test")}), + }, + }, + "null-state-value-unknown-plan": { + // Null state values should not be preserved + request: planmodifier.MapRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Map{ElementType: tftypes.String}, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue( + tftypes.Map{ElementType: tftypes.String}, + nil, + ), + }, + ), + }, + StateValue: types.MapNull(types.StringType), + PlanValue: types.MapUnknown(types.StringType), + ConfigValue: types.MapNull(types.StringType), + }, + expected: &planmodifier.MapResponse{ + PlanValue: types.MapUnknown(types.StringType), + }, + }, + "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.MapRequest{ + StateValue: types.MapValueMust(types.StringType, map[string]attr.Value{"testkey": types.StringValue("test")}), + PlanValue: types.MapUnknown(types.StringType), + ConfigValue: types.MapUnknown(types.StringType), + }, + expected: &planmodifier.MapResponse{ + PlanValue: types.MapUnknown(types.StringType), + }, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.MapResponse{ + PlanValue: testCase.request.PlanValue, + } + + mapplanmodifier.UseNonNullStateForUnknown().PlanModifyMap(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/mapplanmodifier/use_state_for_unknown.go b/resource/schema/mapplanmodifier/use_state_for_unknown.go index 0933cfe98..92e1c9666 100644 --- a/resource/schema/mapplanmodifier/use_state_for_unknown.go +++ b/resource/schema/mapplanmodifier/use_state_for_unknown.go @@ -17,6 +17,10 @@ import ( // 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. +// +// Null is also a known value in Terraform and will be copied to the planned value +// by this plan modifier. For use-cases like a child attribute of a nested attribute or +// if null is desired to be marked as unknown in the case of an update, use [UseNonNullStateForUnknown]. func UseStateForUnknown() planmodifier.Map { return useStateForUnknownModifier{} } diff --git a/resource/schema/objectplanmodifier/use_non_null_state_for_unknown.go b/resource/schema/objectplanmodifier/use_non_null_state_for_unknown.go new file mode 100644 index 000000000..a0fa48c7d --- /dev/null +++ b/resource/schema/objectplanmodifier/use_non_null_state_for_unknown.go @@ -0,0 +1,54 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package objectplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// UseNonNullStateForUnknown returns a plan modifier that copies a known, non-null, prior state +// value into the planned value. Use this when it is known that an unconfigured value will remain the +// same after the attribute is updated to a non-null value. +// +// 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 non-null prior state value in the +// plan, unless a prior plan modifier adjusts the value. +// +// This plan modifier can be a useful alternative to [UseStateForUnknown] when the attribute is +// a child of a nested attribute that can be null after the resource is created. +func UseNonNullStateForUnknown() planmodifier.Object { + return useNonNullStateForUnknown{} +} + +type useNonNullStateForUnknown struct{} + +func (m useNonNullStateForUnknown) Description(_ context.Context) string { + return "Once set to a non-null value, the value of this attribute in state will not change." +} + +func (m useNonNullStateForUnknown) MarkdownDescription(_ context.Context) string { + return "Once set to a non-null value, the value of this attribute in state will not change." +} + +func (m useNonNullStateForUnknown) PlanModifyObject(ctx context.Context, req planmodifier.ObjectRequest, resp *planmodifier.ObjectResponse) { + // Do nothing if the state value is null. + if req.StateValue.IsNull() { + return + } + + // Do nothing if there is a known planned value. + if !req.PlanValue.IsUnknown() { + return + } + + // Do nothing if there is an unknown configuration value, otherwise interpolation gets messed up. + if req.ConfigValue.IsUnknown() { + return + } + + resp.PlanValue = req.StateValue +} diff --git a/resource/schema/objectplanmodifier/use_non_null_state_for_unknown_test.go b/resource/schema/objectplanmodifier/use_non_null_state_for_unknown_test.go new file mode 100644 index 000000000..c87f12220 --- /dev/null +++ b/resource/schema/objectplanmodifier/use_non_null_state_for_unknown_test.go @@ -0,0 +1,168 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package objectplanmodifier_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/objectplanmodifier" + "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 TestUseNonNullStateForUnknownModifierPlanModifyObject(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + request planmodifier.ObjectRequest + expected *planmodifier.ObjectResponse + }{ + "null-state": { + // when we first create the resource, the state value will be null, + // so use the unknown value + request: planmodifier.ObjectRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Object{AttributeTypes: map[string]tftypes.Type{"testattr": tftypes.String}}, + }, + }, + nil, + ), + }, + StateValue: types.ObjectNull(map[string]attr.Type{"testattr": types.StringType}), + PlanValue: types.ObjectUnknown(map[string]attr.Type{"testattr": types.StringType}), + ConfigValue: types.ObjectNull(map[string]attr.Type{"testattr": types.StringType}), + }, + expected: &planmodifier.ObjectResponse{ + PlanValue: types.ObjectUnknown(map[string]attr.Type{"testattr": types.StringType}), + }, + }, + "known-plan": { + // this would really only happen if we had a plan + // modifier setting the value before this plan modifier + // got to it + // + // but we still want to preserve that value, in this + // case + request: planmodifier.ObjectRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Object{AttributeTypes: map[string]tftypes.Type{"testattr": tftypes.String}}, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue( + tftypes.Object{AttributeTypes: map[string]tftypes.Type{"testattr": tftypes.String}}, + map[string]tftypes.Value{ + "testattr": tftypes.NewValue(tftypes.String, "other"), + }, + ), + }, + ), + }, + StateValue: types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("other")}), + PlanValue: types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("test")}), + ConfigValue: types.ObjectNull(map[string]attr.Type{"testattr": types.StringType}), + }, + expected: &planmodifier.ObjectResponse{ + PlanValue: types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("test")}), + }, + }, + "non-null-state-value-unknown-plan": { + // this is the situation we want to preserve the state in + request: planmodifier.ObjectRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Object{AttributeTypes: map[string]tftypes.Type{"testattr": tftypes.String}}, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue( + tftypes.Object{AttributeTypes: map[string]tftypes.Type{"testattr": tftypes.String}}, + map[string]tftypes.Value{ + "testattr": tftypes.NewValue(tftypes.String, "test"), + }, + ), + }, + ), + }, + StateValue: types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("test")}), + PlanValue: types.ObjectUnknown(map[string]attr.Type{"testattr": types.StringType}), + ConfigValue: types.ObjectNull(map[string]attr.Type{"testattr": types.StringType}), + }, + expected: &planmodifier.ObjectResponse{ + PlanValue: types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("test")}), + }, + }, + "null-state-value-unknown-plan": { + // Null state values should not be preserved + request: planmodifier.ObjectRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Object{AttributeTypes: map[string]tftypes.Type{"testattr": tftypes.String}}, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue( + tftypes.Object{AttributeTypes: map[string]tftypes.Type{"testattr": tftypes.String}}, + nil, + ), + }, + ), + }, + StateValue: types.ObjectNull(map[string]attr.Type{"testattr": types.StringType}), + PlanValue: types.ObjectUnknown(map[string]attr.Type{"testattr": types.StringType}), + ConfigValue: types.ObjectNull(map[string]attr.Type{"testattr": types.StringType}), + }, + expected: &planmodifier.ObjectResponse{ + PlanValue: types.ObjectUnknown(map[string]attr.Type{"testattr": types.StringType}), + }, + }, + "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.ObjectRequest{ + StateValue: types.ObjectValueMust(map[string]attr.Type{"testattr": types.StringType}, map[string]attr.Value{"testattr": types.StringValue("test")}), + PlanValue: types.ObjectUnknown(map[string]attr.Type{"testattr": types.StringType}), + ConfigValue: types.ObjectUnknown(map[string]attr.Type{"testattr": types.StringType}), + }, + expected: &planmodifier.ObjectResponse{ + PlanValue: types.ObjectUnknown(map[string]attr.Type{"testattr": types.StringType}), + }, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.ObjectResponse{ + PlanValue: testCase.request.PlanValue, + } + + objectplanmodifier.UseNonNullStateForUnknown().PlanModifyObject(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/objectplanmodifier/use_state_for_unknown.go b/resource/schema/objectplanmodifier/use_state_for_unknown.go index 3e5b7e67f..d93b567d5 100644 --- a/resource/schema/objectplanmodifier/use_state_for_unknown.go +++ b/resource/schema/objectplanmodifier/use_state_for_unknown.go @@ -17,6 +17,10 @@ import ( // 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. +// +// Null is also a known value in Terraform and will be copied to the planned value +// by this plan modifier. For use-cases like a child attribute of a nested attribute or +// if null is desired to be marked as unknown in the case of an update, use [UseNonNullStateForUnknown]. func UseStateForUnknown() planmodifier.Object { return useStateForUnknownModifier{} } diff --git a/resource/schema/setplanmodifier/use_non_null_state_for_unknown.go b/resource/schema/setplanmodifier/use_non_null_state_for_unknown.go new file mode 100644 index 000000000..791dd9243 --- /dev/null +++ b/resource/schema/setplanmodifier/use_non_null_state_for_unknown.go @@ -0,0 +1,54 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package setplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// UseNonNullStateForUnknown returns a plan modifier that copies a known, non-null, prior state +// value into the planned value. Use this when it is known that an unconfigured value will remain the +// same after the attribute is updated to a non-null value. +// +// 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 non-null prior state value in the +// plan, unless a prior plan modifier adjusts the value. +// +// This plan modifier can be a useful alternative to [UseStateForUnknown] when the attribute is +// a child of a nested attribute that can be null after the resource is created. +func UseNonNullStateForUnknown() planmodifier.Set { + return useNonNullStateForUnknown{} +} + +type useNonNullStateForUnknown struct{} + +func (m useNonNullStateForUnknown) Description(_ context.Context) string { + return "Once set to a non-null value, the value of this attribute in state will not change." +} + +func (m useNonNullStateForUnknown) MarkdownDescription(_ context.Context) string { + return "Once set to a non-null value, the value of this attribute in state will not change." +} + +func (m useNonNullStateForUnknown) PlanModifySet(ctx context.Context, req planmodifier.SetRequest, resp *planmodifier.SetResponse) { + // Do nothing if the state value is null. + if req.StateValue.IsNull() { + return + } + + // Do nothing if there is a known planned value. + if !req.PlanValue.IsUnknown() { + return + } + + // Do nothing if there is an unknown configuration value, otherwise interpolation gets messed up. + if req.ConfigValue.IsUnknown() { + return + } + + resp.PlanValue = req.StateValue +} diff --git a/resource/schema/setplanmodifier/use_non_null_state_for_unknown_test.go b/resource/schema/setplanmodifier/use_non_null_state_for_unknown_test.go new file mode 100644 index 000000000..9159096ec --- /dev/null +++ b/resource/schema/setplanmodifier/use_non_null_state_for_unknown_test.go @@ -0,0 +1,168 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package setplanmodifier_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/setplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestUseNonNullStateForUnknownModifierPlanModifySet(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + request planmodifier.SetRequest + expected *planmodifier.SetResponse + }{ + "null-state": { + // when we first create the resource, the state value will be null, + // so use the unknown value + request: planmodifier.SetRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Set{ElementType: tftypes.String}, + }, + }, + nil, + ), + }, + StateValue: types.SetNull(types.StringType), + PlanValue: types.SetUnknown(types.StringType), + ConfigValue: types.SetNull(types.StringType), + }, + expected: &planmodifier.SetResponse{ + PlanValue: types.SetUnknown(types.StringType), + }, + }, + "known-plan": { + // this would really only happen if we had a plan + // modifier setting the value before this plan modifier + // got to it + // + // but we still want to preserve that value, in this + // case + request: planmodifier.SetRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Set{ElementType: tftypes.String}, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue( + tftypes.Set{ElementType: tftypes.String}, + []tftypes.Value{ + tftypes.NewValue(tftypes.String, "other"), + }, + ), + }, + ), + }, + StateValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("other")}), + PlanValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + ConfigValue: types.SetNull(types.StringType), + }, + expected: &planmodifier.SetResponse{ + PlanValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + }, + }, + "non-null-state-value-unknown-plan": { + // this is the situation we want to preserve the state in + request: planmodifier.SetRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Set{ElementType: tftypes.String}, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue( + tftypes.Set{ElementType: tftypes.String}, + []tftypes.Value{ + tftypes.NewValue(tftypes.String, "test"), + }, + ), + }, + ), + }, + StateValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + PlanValue: types.SetUnknown(types.StringType), + ConfigValue: types.SetNull(types.StringType), + }, + expected: &planmodifier.SetResponse{ + PlanValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + }, + }, + "null-state-value-unknown-plan": { + // Null state values should not be preserved + request: planmodifier.SetRequest{ + State: tfsdk.State{ + Raw: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "attr": tftypes.Set{ElementType: tftypes.String}, + }, + }, + map[string]tftypes.Value{ + "attr": tftypes.NewValue( + tftypes.Set{ElementType: tftypes.String}, + nil, + ), + }, + ), + }, + StateValue: types.SetNull(types.StringType), + PlanValue: types.SetUnknown(types.StringType), + ConfigValue: types.SetNull(types.StringType), + }, + expected: &planmodifier.SetResponse{ + PlanValue: types.SetUnknown(types.StringType), + }, + }, + "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.SetRequest{ + StateValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("test")}), + PlanValue: types.SetUnknown(types.StringType), + ConfigValue: types.SetUnknown(types.StringType), + }, + expected: &planmodifier.SetResponse{ + PlanValue: types.SetUnknown(types.StringType), + }, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.SetResponse{ + PlanValue: testCase.request.PlanValue, + } + + setplanmodifier.UseNonNullStateForUnknown().PlanModifySet(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/resource/schema/setplanmodifier/use_state_for_unknown.go b/resource/schema/setplanmodifier/use_state_for_unknown.go index 1bff4e27a..0d96c98db 100644 --- a/resource/schema/setplanmodifier/use_state_for_unknown.go +++ b/resource/schema/setplanmodifier/use_state_for_unknown.go @@ -17,6 +17,10 @@ import ( // 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. +// +// Null is also a known value in Terraform and will be copied to the planned value +// by this plan modifier. For use-cases like a child attribute of a nested attribute or +// if null is desired to be marked as unknown in the case of an update, use [UseNonNullStateForUnknown]. func UseStateForUnknown() planmodifier.Set { return useStateForUnknownModifier{} } From 601f5d8d48d51afb05c505403ada8f861c154d7a Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Fri, 21 Nov 2025 17:11:47 -0500 Subject: [PATCH 4/5] add changelogs --- .changes/unreleased/FEATURES-20251121-165955.yaml | 6 ++++++ .changes/unreleased/NOTES-20251121-170117.yaml | 9 +++++++++ 2 files changed, 15 insertions(+) create mode 100644 .changes/unreleased/FEATURES-20251121-165955.yaml create mode 100644 .changes/unreleased/NOTES-20251121-170117.yaml diff --git a/.changes/unreleased/FEATURES-20251121-165955.yaml b/.changes/unreleased/FEATURES-20251121-165955.yaml new file mode 100644 index 000000000..b393af188 --- /dev/null +++ b/.changes/unreleased/FEATURES-20251121-165955.yaml @@ -0,0 +1,6 @@ +kind: FEATURES +body: 'all: Added a new plan modifier for all types, `UseNonNullStateForUnknown` that preserves known, non-null, values for unconfigured attributes. + This can be used when it is known that an unconfigured value will remain the same after the attribute is updated to a non-null value.' +time: 2025-11-21T16:59:55.611735-05:00 +custom: + Issue: "1242" diff --git a/.changes/unreleased/NOTES-20251121-170117.yaml b/.changes/unreleased/NOTES-20251121-170117.yaml new file mode 100644 index 000000000..3b29f93e4 --- /dev/null +++ b/.changes/unreleased/NOTES-20251121-170117.yaml @@ -0,0 +1,9 @@ +kind: NOTES +body: 'In `terraform-plugin-framework@v1.15.1`, the `UseStateForUnknown` plan modifier was updated to preserve null values from prior state + for unconfigured attributes. This updated version can cause plan inconsistency errors when used on child attributes of a nested attribute + that expect `UseStateForUnknown` to keep the child attributes on new nested objects as `` (known after apply). + + The new `UseNonNullStateForUnknown` plan modifier can now be used where child attributes are expecting this pre-1.15.1 behavior.' +time: 2025-11-21T17:01:17.961155-05:00 +custom: + Issue: "1197" From e6fe28405f954553da0a669bf16628d6f965e73e Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Fri, 21 Nov 2025 17:16:38 -0500 Subject: [PATCH 5/5] update 1.15.1 changelog --- .changes/1.15.1.md | 4 ++++ CHANGELOG.md | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/.changes/1.15.1.md b/.changes/1.15.1.md index 8db7bc634..861281a4d 100644 --- a/.changes/1.15.1.md +++ b/.changes/1.15.1.md @@ -1,5 +1,9 @@ ## 1.15.1 (July 31, 2025) +NOTES: + +* This release contains a change in behavior for the `UseStateForUnknown` plan modifier, which will now preserve null values from prior state for unconfigured attributes. This updated version can cause plan inconsistency errors when used on child attributes of a nested attribute that expect `UseStateForUnknown` to keep the child attributes on new nested objects as `` (known after apply). See [#1197](https://github.com/hashicorp/terraform-plugin-framework/issues/1197) for more details. + BUG FIXES: * all: Fixed bug with `UseStateForUnknown` where known null state values were not preserved during update plans. ([#1117](https://github.com/hashicorp/terraform-plugin-framework/issues/1117)) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dbc76013..24516a24f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,10 @@ BUG FIXES: ## 1.15.1 (July 31, 2025) +NOTES: + +* This release contains a change in behavior for the `UseStateForUnknown` plan modifier, which will now preserve null values from prior state for unconfigured attributes. This updated version can cause plan inconsistency errors when used on child attributes of a nested attribute that expect `UseStateForUnknown` to keep the child attributes on new nested objects as `` (known after apply). See [#1197](https://github.com/hashicorp/terraform-plugin-framework/issues/1197) for more details. + BUG FIXES: * all: Fixed bug with `UseStateForUnknown` where known null state values were not preserved during update plans. ([#1117](https://github.com/hashicorp/terraform-plugin-framework/issues/1117))