diff --git a/attr/value.go b/attr/value.go index b34a3bb7..7e8793fe 100644 --- a/attr/value.go +++ b/attr/value.go @@ -6,6 +6,7 @@ package attr import ( "context" + "github.com/hashicorp/terraform-plugin-framework/types/refinement" "github.com/hashicorp/terraform-plugin-go/tftypes" ) @@ -69,3 +70,17 @@ type Value interface { // compatibility guarantees within the framework. String() string } + +// ValueWithNotNullRefinement defines an interface describing a Value that can contain +// a refinement that indicates the Value is unknown, but will not be null once it becomes known. +// +// This interface is implemented by all base value types except for DynamicValue, as dynamic types +// in Terraform don't support value refinements. +type ValueWithNotNullRefinement interface { + Value + + // NotNullRefinement returns value refinement data and a boolean indicating if a NotNull refinement + // exists on the given Value. If a Value contains a NotNull refinement, this indicates that the value + // is unknown, but the eventual known value will not be null. + NotNullRefinement() (*refinement.NotNull, bool) +} diff --git a/go.mod b/go.mod index 193d48e3..b44594f6 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ toolchain go1.22.7 require ( github.com/google/go-cmp v0.6.0 - github.com/hashicorp/terraform-plugin-go v0.25.0 + github.com/hashicorp/terraform-plugin-go v0.25.1-0.20241126200214-bd716fcfe407 github.com/hashicorp/terraform-plugin-log v0.9.0 ) @@ -30,5 +30,5 @@ require ( golang.org/x/text v0.17.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 // indirect google.golang.org/grpc v1.67.1 // indirect - google.golang.org/protobuf v1.35.1 // indirect + google.golang.org/protobuf v1.35.2 // indirect ) diff --git a/go.sum b/go.sum index 99e7874b..ab5b2744 100644 --- a/go.sum +++ b/go.sum @@ -15,8 +15,8 @@ github.com/hashicorp/go-plugin v1.6.2 h1:zdGAEd0V1lCaU0u+MxWQhtSDQmahpkwOun8U8Ei github.com/hashicorp/go-plugin v1.6.2/go.mod h1:CkgLQ5CZqNmdL9U9JzM532t8ZiYQ35+pj3b1FD37R0Q= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/terraform-plugin-go v0.25.0 h1:oi13cx7xXA6QciMcpcFi/rwA974rdTxjqEhXJjbAyks= -github.com/hashicorp/terraform-plugin-go v0.25.0/go.mod h1:+SYagMYadJP86Kvn+TGeV+ofr/R3g4/If0O5sO96MVw= +github.com/hashicorp/terraform-plugin-go v0.25.1-0.20241126200214-bd716fcfe407 h1:oLzKb+YiJIEq0EY3qGgQTxCLW2CaXN1rJp3yg1H11qI= +github.com/hashicorp/terraform-plugin-go v0.25.1-0.20241126200214-bd716fcfe407/go.mod h1:f8P2pHGkZrtdKLpCI2qIvrewUY+c4nTvtayqjJR9IcY= github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0= github.com/hashicorp/terraform-plugin-log v0.9.0/go.mod h1:rKL8egZQ/eXSyDqzLUuwUYLVdlYeamldAHSxjUFADow= github.com/hashicorp/terraform-registry-address v0.2.3 h1:2TAiKJ1A3MAkZlH1YI/aTVcLZRu7JseiXNRHbOAyoTI= @@ -64,8 +64,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 h1: google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E= google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA= -google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= -google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io= +google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/fwserver/attribute_validation.go b/internal/fwserver/attribute_validation.go index 98b05f0f..83333c8d 100644 --- a/internal/fwserver/attribute_validation.go +++ b/internal/fwserver/attribute_validation.go @@ -19,7 +19,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/types/basetypes" ) -// ValidateAttributeRequest repesents a request for attribute validation. +// ValidateAttributeRequest represents a request for attribute validation. type ValidateAttributeRequest struct { // AttributePath contains the path of the attribute. Use this path for any // response diagnostics. @@ -137,33 +137,48 @@ func AttributeValidate(ctx context.Context, a fwschema.Attribute, req ValidateAt AttributeValidateNestedAttributes(ctx, a, req, resp) - // Show deprecation warnings only for known values. - if a.GetDeprecationMessage() != "" && !attributeConfig.IsNull() && !attributeConfig.IsUnknown() { - // Dynamic values need to perform more logic to check the config value for null/unknown-ness - dynamicValuable, ok := attributeConfig.(basetypes.DynamicValuable) - if !ok { - resp.Diagnostics.AddAttributeWarning( - req.AttributePath, - "Attribute Deprecated", - a.GetDeprecationMessage(), - ) - return - } + // Show deprecation warnings only for known values or unknown values with a "not null" refinement. + if a.GetDeprecationMessage() != "" { + if attributeConfig.IsUnknown() { + // If the unknown value will eventually be not null, we return the deprecation message for the practitioner. + val, ok := attributeConfig.(attr.ValueWithNotNullRefinement) + if ok { + if _, notNull := val.NotNullRefinement(); notNull { + resp.Diagnostics.AddAttributeWarning( + req.AttributePath, + "Attribute Deprecated", + a.GetDeprecationMessage(), + ) + return + } + } + } else if !attributeConfig.IsNull() && !attributeConfig.IsUnknown() { + // Dynamic values need to perform more logic to check the config value for null/unknown-ness + dynamicValuable, ok := attributeConfig.(basetypes.DynamicValuable) + if !ok { + resp.Diagnostics.AddAttributeWarning( + req.AttributePath, + "Attribute Deprecated", + a.GetDeprecationMessage(), + ) + return + } - dynamicConfigVal, diags := dynamicValuable.ToDynamicValue(ctx) - resp.Diagnostics.Append(diags...) - if diags.HasError() { - return - } + dynamicConfigVal, diags := dynamicValuable.ToDynamicValue(ctx) + resp.Diagnostics.Append(diags...) + if diags.HasError() { + return + } - // For dynamic values, it's possible to be known when only the type is known. - // The underlying value can still be null or unknown, so check for that here - if !dynamicConfigVal.IsUnderlyingValueNull() && !dynamicConfigVal.IsUnderlyingValueUnknown() { - resp.Diagnostics.AddAttributeWarning( - req.AttributePath, - "Attribute Deprecated", - a.GetDeprecationMessage(), - ) + // For dynamic values, it's possible to be known when only the type is known. + // The underlying value can still be null or unknown, so check for that here + if !dynamicConfigVal.IsUnderlyingValueNull() && !dynamicConfigVal.IsUnderlyingValueUnknown() { + resp.Diagnostics.AddAttributeWarning( + req.AttributePath, + "Attribute Deprecated", + a.GetDeprecationMessage(), + ) + } } } } diff --git a/internal/fwserver/attribute_validation_test.go b/internal/fwserver/attribute_validation_test.go index 457264e0..fc615c3a 100644 --- a/internal/fwserver/attribute_validation_test.go +++ b/internal/fwserver/attribute_validation_test.go @@ -11,6 +11,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform-plugin-go/tftypes" + tfrefinement "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" @@ -490,6 +491,40 @@ func TestAttributeValidate(t *testing.T) { }, resp: ValidateAttributeResponse{}, }, + "deprecation-message-unknown-with-not-null-refinement": { + req: ValidateAttributeRequest{ + AttributePath: path.Root("test"), + Config: tfsdk.Config{ + Raw: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test": tftypes.String, + }, + }, map[string]tftypes.Value{ + "test": tftypes.NewValue(tftypes.String, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + }), + }), + Schema: testschema.Schema{ + Attributes: map[string]fwschema.Attribute{ + "test": testschema.Attribute{ + Type: types.StringType, + Optional: true, + DeprecationMessage: "Use something else instead.", + }, + }, + }, + }, + }, + resp: ValidateAttributeResponse{ + Diagnostics: diag.Diagnostics{ + diag.NewAttributeWarningDiagnostic( + path.Root("test"), + "Attribute Deprecated", + "Use something else instead.", + ), + }, + }, + }, "deprecation-message-dynamic-underlying-value-unknown": { req: ValidateAttributeRequest{ AttributePath: path.Root("test"), diff --git a/internal/testing/testtypes/numberwithvalidateattribute.go b/internal/testing/testtypes/numberwithvalidateattribute.go index 09067e74..854c3ce0 100644 --- a/internal/testing/testtypes/numberwithvalidateattribute.go +++ b/internal/testing/testtypes/numberwithvalidateattribute.go @@ -64,7 +64,7 @@ func (v NumberValueWithValidateAttributeError) Equal(value attr.Value) bool { return false } - return v == other + return v.Equal(other) } func (v NumberValueWithValidateAttributeError) IsNull() bool { @@ -92,7 +92,7 @@ func (t NumberTypeWithValidateAttributeWarning) Equal(o attr.Type) bool { if !ok { return false } - return t == other + return t.NumberType.Equal(other.NumberType) } func (t NumberTypeWithValidateAttributeWarning) ValueFromTerraform(ctx context.Context, in tftypes.Value) (attr.Value, error) { @@ -134,7 +134,7 @@ func (v NumberValueWithValidateAttributeWarning) Equal(value attr.Value) bool { return false } - return v.InternalNumber.Number.Equal(other.InternalNumber.Number) + return v.InternalNumber.Equal(other.InternalNumber) } func (v NumberValueWithValidateAttributeWarning) IsNull() bool { diff --git a/internal/testing/testtypes/stringwithvalidateattribute.go b/internal/testing/testtypes/stringwithvalidateattribute.go index cb6440db..17b26229 100644 --- a/internal/testing/testtypes/stringwithvalidateattribute.go +++ b/internal/testing/testtypes/stringwithvalidateattribute.go @@ -64,7 +64,7 @@ func (v StringValueWithValidateAttributeError) Equal(value attr.Value) bool { return false } - return v == other + return v.InternalString.Equal(other.InternalString) } func (v StringValueWithValidateAttributeError) IsNull() bool { @@ -134,7 +134,7 @@ func (v StringValueWithValidateAttributeWarning) Equal(value attr.Value) bool { return false } - return v == other + return v.InternalString.Equal(other.InternalString) } func (v StringValueWithValidateAttributeWarning) IsNull() bool { diff --git a/internal/testing/testtypes/stringwithvalidateparameter.go b/internal/testing/testtypes/stringwithvalidateparameter.go index 751bbcd4..7ac8e929 100644 --- a/internal/testing/testtypes/stringwithvalidateparameter.go +++ b/internal/testing/testtypes/stringwithvalidateparameter.go @@ -64,7 +64,7 @@ func (v StringValueWithValidateParameterError) Equal(value attr.Value) bool { return false } - return v == other + return v.InternalString.Equal(other.InternalString) } func (v StringValueWithValidateParameterError) IsNull() bool { diff --git a/resource/schema/boolplanmodifier/will_not_be_null.go b/resource/schema/boolplanmodifier/will_not_be_null.go new file mode 100644 index 00000000..f2a21155 --- /dev/null +++ b/resource/schema/boolplanmodifier/will_not_be_null.go @@ -0,0 +1,50 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package boolplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// WillNotBeNull returns a plan modifier that will add a refinement to an unknown planned value +// which promises that the final value will not be null. +// +// This unknown value refinement allows Terraform to validate more of the configuration during plan +// and evaluate conditional logic in meta-arguments such as "count": +// +// resource "examplecloud_thing" "b" { +// // Will successfully evaluate during plan with a "not null" refinement on "bool_attribute" +// count = examplecloud_thing.a.bool_attribute != null ? 1 : 0 +// +// // .. resource config +// } +func WillNotBeNull() planmodifier.Bool { + return willNotBeNullModifier{} +} + +type willNotBeNullModifier struct{} + +func (m willNotBeNullModifier) Description(_ context.Context) string { + return "Promises the value of this attribute will not be null once it becomes known" +} + +func (m willNotBeNullModifier) MarkdownDescription(_ context.Context) string { + return "Promises the value of this attribute will not be null once it becomes known" +} + +func (m willNotBeNullModifier) PlanModifyBool(ctx context.Context, req planmodifier.BoolRequest, resp *planmodifier.BoolResponse) { + // 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.PlanValue.RefineAsNotNull() +} diff --git a/resource/schema/boolplanmodifier/will_not_be_null_test.go b/resource/schema/boolplanmodifier/will_not_be_null_test.go new file mode 100644 index 00000000..b55a531b --- /dev/null +++ b/resource/schema/boolplanmodifier/will_not_be_null_test.go @@ -0,0 +1,88 @@ +// 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/types" +) + +func TestWillNotBeNullModifierPlanModifyBool(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + request planmodifier.BoolRequest + expected *planmodifier.BoolResponse + }{ + "known-plan": { + request: planmodifier.BoolRequest{ + StateValue: types.BoolValue(false), + PlanValue: types.BoolValue(true), + ConfigValue: types.BoolNull(), + }, + expected: &planmodifier.BoolResponse{ + PlanValue: types.BoolValue(true), + }, + }, + "unknown-config": { + // this is the situation in which a user is + // interpolating into a field. We want that to still + // show up as unknown (with no refinement), 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{ + StateValue: types.BoolValue(true), + PlanValue: types.BoolUnknown(), + ConfigValue: types.BoolUnknown(), + }, + expected: &planmodifier.BoolResponse{ + PlanValue: types.BoolUnknown(), + }, + }, + "unknown-plan-null-state": { + request: planmodifier.BoolRequest{ + StateValue: types.BoolNull(), + PlanValue: types.BoolUnknown(), + ConfigValue: types.BoolNull(), + }, + expected: &planmodifier.BoolResponse{ + PlanValue: types.BoolUnknown().RefineAsNotNull(), + }, + }, + "unknown-plan-non-null-state": { + request: planmodifier.BoolRequest{ + StateValue: types.BoolValue(true), + PlanValue: types.BoolUnknown(), + ConfigValue: types.BoolNull(), + }, + expected: &planmodifier.BoolResponse{ + PlanValue: types.BoolUnknown().RefineAsNotNull(), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.BoolResponse{ + PlanValue: testCase.request.PlanValue, + } + + boolplanmodifier.WillNotBeNull().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/float32planmodifier/will_be_at_least.go b/resource/schema/float32planmodifier/will_be_at_least.go new file mode 100644 index 00000000..b74f4673 --- /dev/null +++ b/resource/schema/float32planmodifier/will_be_at_least.go @@ -0,0 +1,50 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package float32planmodifier + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// WillBeAtLeast returns a plan modifier that will add a refinement to an unknown planned value +// which promises that: +// - The final value will not be null. +// - The final value will be greater than or equal to the provided minimum value. +// +// This unknown value refinement allows Terraform to validate more of the configuration during plan +// and evaluate conditional logic in meta-arguments such as "count". +func WillBeAtLeast(minVal float32) planmodifier.Float32 { + return willBeAtLeastModifier{ + min: minVal, + } +} + +type willBeAtLeastModifier struct { + min float32 +} + +func (m willBeAtLeastModifier) Description(_ context.Context) string { + return fmt.Sprintf("Promises the value of this attribute will be at least %f once it becomes known", m.min) +} + +func (m willBeAtLeastModifier) MarkdownDescription(_ context.Context) string { + return fmt.Sprintf("Promises the value of this attribute will be at least %f once it becomes known", m.min) +} + +func (m willBeAtLeastModifier) PlanModifyFloat32(ctx context.Context, req planmodifier.Float32Request, resp *planmodifier.Float32Response) { + // 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.PlanValue.RefineWithLowerBound(m.min, true) +} diff --git a/resource/schema/float32planmodifier/will_be_at_least_test.go b/resource/schema/float32planmodifier/will_be_at_least_test.go new file mode 100644 index 00000000..f2c0a52e --- /dev/null +++ b/resource/schema/float32planmodifier/will_be_at_least_test.go @@ -0,0 +1,104 @@ +// 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/types" +) + +func TestWillBeAtLeastModifierPlanModifyFloat32(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + minVal float32 + request planmodifier.Float32Request + expected *planmodifier.Float32Response + }{ + "known-plan": { + minVal: 5.5, + request: planmodifier.Float32Request{ + StateValue: types.Float32Value(5), + PlanValue: types.Float32Value(10), + ConfigValue: types.Float32Null(), + }, + expected: &planmodifier.Float32Response{ + PlanValue: types.Float32Value(10), + }, + }, + "unknown-config": { + // this is the situation in which a user is + // interpolating into a field. We want that to still + // show up as unknown (with no refinement), 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 + minVal: 5.5, + request: planmodifier.Float32Request{ + StateValue: types.Float32Value(10), + PlanValue: types.Float32Unknown(), + ConfigValue: types.Float32Unknown(), + }, + expected: &planmodifier.Float32Response{ + PlanValue: types.Float32Unknown(), + }, + }, + "unknown-plan-null-state": { + minVal: 5.5, + request: planmodifier.Float32Request{ + StateValue: types.Float32Null(), + PlanValue: types.Float32Unknown(), + ConfigValue: types.Float32Null(), + }, + expected: &planmodifier.Float32Response{ + PlanValue: types.Float32Unknown().RefineWithLowerBound(5.5, true), + }, + }, + "unknown-plan-non-null-state": { + minVal: 3.5, + request: planmodifier.Float32Request{ + StateValue: types.Float32Value(10), + PlanValue: types.Float32Unknown(), + ConfigValue: types.Float32Null(), + }, + expected: &planmodifier.Float32Response{ + PlanValue: types.Float32Unknown().RefineWithLowerBound(3.5, true), + }, + }, + "unknown-plan-preserve-existing-refinement": { + minVal: 2.5, + request: planmodifier.Float32Request{ + StateValue: types.Float32Null(), + PlanValue: types.Float32Unknown().RefineWithUpperBound(6, false), + ConfigValue: types.Float32Null(), + }, + expected: &planmodifier.Float32Response{ + PlanValue: types.Float32Unknown().RefineWithUpperBound(6, false).RefineWithLowerBound(2.5, true), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.Float32Response{ + PlanValue: testCase.request.PlanValue, + } + + float32planmodifier.WillBeAtLeast(testCase.minVal).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/will_be_at_most.go b/resource/schema/float32planmodifier/will_be_at_most.go new file mode 100644 index 00000000..f514e4c2 --- /dev/null +++ b/resource/schema/float32planmodifier/will_be_at_most.go @@ -0,0 +1,50 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package float32planmodifier + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// WillBeAtMost returns a plan modifier that will add a refinement to an unknown planned value +// which promises that: +// - The final value will not be null. +// - The final value will be less than or equal to the provided maximum value. +// +// This unknown value refinement allows Terraform to validate more of the configuration during plan +// and evaluate conditional logic in meta-arguments such as "count". +func WillBeAtMost(maxVal float32) planmodifier.Float32 { + return willBeAtMostModifier{ + max: maxVal, + } +} + +type willBeAtMostModifier struct { + max float32 +} + +func (m willBeAtMostModifier) Description(_ context.Context) string { + return fmt.Sprintf("Promises the value of this attribute will be at most %f once it becomes known", m.max) +} + +func (m willBeAtMostModifier) MarkdownDescription(_ context.Context) string { + return fmt.Sprintf("Promises the value of this attribute will be at most %f once it becomes known", m.max) +} + +func (m willBeAtMostModifier) PlanModifyFloat32(ctx context.Context, req planmodifier.Float32Request, resp *planmodifier.Float32Response) { + // 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.PlanValue.RefineWithUpperBound(m.max, true) +} diff --git a/resource/schema/float32planmodifier/will_be_at_most_test.go b/resource/schema/float32planmodifier/will_be_at_most_test.go new file mode 100644 index 00000000..56821bfa --- /dev/null +++ b/resource/schema/float32planmodifier/will_be_at_most_test.go @@ -0,0 +1,104 @@ +// 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/types" +) + +func TestWillBeAtMostModifierPlanModifyFloat32(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + maxVal float32 + request planmodifier.Float32Request + expected *planmodifier.Float32Response + }{ + "known-plan": { + maxVal: 10.1, + request: planmodifier.Float32Request{ + StateValue: types.Float32Value(5), + PlanValue: types.Float32Value(10), + ConfigValue: types.Float32Null(), + }, + expected: &planmodifier.Float32Response{ + PlanValue: types.Float32Value(10), + }, + }, + "unknown-config": { + // this is the situation in which a user is + // interpolating into a field. We want that to still + // show up as unknown (with no refinement), 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 + maxVal: 10.1, + request: planmodifier.Float32Request{ + StateValue: types.Float32Value(10), + PlanValue: types.Float32Unknown(), + ConfigValue: types.Float32Unknown(), + }, + expected: &planmodifier.Float32Response{ + PlanValue: types.Float32Unknown(), + }, + }, + "unknown-plan-null-state": { + maxVal: 10.1, + request: planmodifier.Float32Request{ + StateValue: types.Float32Null(), + PlanValue: types.Float32Unknown(), + ConfigValue: types.Float32Null(), + }, + expected: &planmodifier.Float32Response{ + PlanValue: types.Float32Unknown().RefineWithUpperBound(10.1, true), + }, + }, + "unknown-plan-non-null-state": { + maxVal: 4.1, + request: planmodifier.Float32Request{ + StateValue: types.Float32Value(10), + PlanValue: types.Float32Unknown(), + ConfigValue: types.Float32Null(), + }, + expected: &planmodifier.Float32Response{ + PlanValue: types.Float32Unknown().RefineWithUpperBound(4.1, true), + }, + }, + "unknown-plan-preserve-existing-refinement": { + maxVal: 6.1, + request: planmodifier.Float32Request{ + StateValue: types.Float32Null(), + PlanValue: types.Float32Unknown().RefineWithLowerBound(2, false), + ConfigValue: types.Float32Null(), + }, + expected: &planmodifier.Float32Response{ + PlanValue: types.Float32Unknown().RefineWithLowerBound(2, false).RefineWithUpperBound(6.1, true), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.Float32Response{ + PlanValue: testCase.request.PlanValue, + } + + float32planmodifier.WillBeAtMost(testCase.maxVal).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/will_be_between.go b/resource/schema/float32planmodifier/will_be_between.go new file mode 100644 index 00000000..8b830509 --- /dev/null +++ b/resource/schema/float32planmodifier/will_be_between.go @@ -0,0 +1,55 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package float32planmodifier + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// WillBeBetween returns a plan modifier that will add a refinement to an unknown planned value +// which promises that: +// - The final value will not be null. +// - The final value will be greater than or equal to the provided minimum value. +// - The final value will be less than or equal to the provided maximum value. +// +// This unknown value refinement allows Terraform to validate more of the configuration during plan +// and evaluate conditional logic in meta-arguments such as "count". +func WillBeBetween(minVal, maxVal float32) planmodifier.Float32 { + return willBeBetweenModifier{ + min: minVal, + max: maxVal, + } +} + +type willBeBetweenModifier struct { + min float32 + max float32 +} + +func (m willBeBetweenModifier) Description(_ context.Context) string { + return fmt.Sprintf("Promises the value of this attribute will be between %f and %f once it becomes known", m.min, m.max) +} + +func (m willBeBetweenModifier) MarkdownDescription(_ context.Context) string { + return fmt.Sprintf("Promises the value of this attribute will be between %f and %f once it becomes known", m.min, m.max) +} + +func (m willBeBetweenModifier) PlanModifyFloat32(ctx context.Context, req planmodifier.Float32Request, resp *planmodifier.Float32Response) { + // 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.PlanValue. + RefineWithLowerBound(m.min, true). + RefineWithUpperBound(m.max, true) +} diff --git a/resource/schema/float32planmodifier/will_be_between_test.go b/resource/schema/float32planmodifier/will_be_between_test.go new file mode 100644 index 00000000..29501721 --- /dev/null +++ b/resource/schema/float32planmodifier/will_be_between_test.go @@ -0,0 +1,110 @@ +// 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/types" +) + +func TestWillBeBetweenModifierPlanModifyFloat32(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + minVal float32 + maxVal float32 + request planmodifier.Float32Request + expected *planmodifier.Float32Response + }{ + "known-plan": { + minVal: 5.5, + maxVal: 10.1, + request: planmodifier.Float32Request{ + StateValue: types.Float32Value(5.5), + PlanValue: types.Float32Value(10.1), + ConfigValue: types.Float32Null(), + }, + expected: &planmodifier.Float32Response{ + PlanValue: types.Float32Value(10.1), + }, + }, + "unknown-config": { + // this is the situation in which a user is + // interpolating into a field. We want that to still + // show up as unknown (with no refinement), 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 + minVal: 5.5, + maxVal: 10.1, + request: planmodifier.Float32Request{ + StateValue: types.Float32Value(10.1), + PlanValue: types.Float32Unknown(), + ConfigValue: types.Float32Unknown(), + }, + expected: &planmodifier.Float32Response{ + PlanValue: types.Float32Unknown(), + }, + }, + "unknown-plan-null-state": { + minVal: 5.5, + maxVal: 10.1, + request: planmodifier.Float32Request{ + StateValue: types.Float32Null(), + PlanValue: types.Float32Unknown(), + ConfigValue: types.Float32Null(), + }, + expected: &planmodifier.Float32Response{ + PlanValue: types.Float32Unknown().RefineWithLowerBound(5.5, true).RefineWithUpperBound(10.1, true), + }, + }, + "unknown-plan-non-null-state": { + minVal: 3.5, + maxVal: 4.1, + request: planmodifier.Float32Request{ + StateValue: types.Float32Value(10.1), + PlanValue: types.Float32Unknown(), + ConfigValue: types.Float32Null(), + }, + expected: &planmodifier.Float32Response{ + PlanValue: types.Float32Unknown().RefineWithLowerBound(3.5, true).RefineWithUpperBound(4.1, true), + }, + }, + "unknown-plan-preserve-existing-refinement": { + minVal: 2.5, + maxVal: 6.1, + request: planmodifier.Float32Request{ + StateValue: types.Float32Null(), + PlanValue: types.Float32Unknown().RefineAsNotNull(), + ConfigValue: types.Float32Null(), + }, + expected: &planmodifier.Float32Response{ + PlanValue: types.Float32Unknown().RefineAsNotNull().RefineWithLowerBound(2.5, true).RefineWithUpperBound(6.1, true), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.Float32Response{ + PlanValue: testCase.request.PlanValue, + } + + float32planmodifier.WillBeBetween(testCase.minVal, testCase.maxVal).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/will_not_be_null.go b/resource/schema/float32planmodifier/will_not_be_null.go new file mode 100644 index 00000000..ebe44003 --- /dev/null +++ b/resource/schema/float32planmodifier/will_not_be_null.go @@ -0,0 +1,50 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package float32planmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// WillNotBeNull returns a plan modifier that will add a refinement to an unknown planned value +// which promises that the final value will not be null. +// +// This unknown value refinement allows Terraform to validate more of the configuration during plan +// and evaluate conditional logic in meta-arguments such as "count": +// +// resource "examplecloud_thing" "b" { +// // Will successfully evaluate during plan with a "not null" refinement on "float32_attribute" +// count = examplecloud_thing.a.float32_attribute != null ? 1 : 0 +// +// // .. resource config +// } +func WillNotBeNull() planmodifier.Float32 { + return willNotBeNullModifier{} +} + +type willNotBeNullModifier struct{} + +func (m willNotBeNullModifier) Description(_ context.Context) string { + return "Promises the value of this attribute will not be null once it becomes known" +} + +func (m willNotBeNullModifier) MarkdownDescription(_ context.Context) string { + return "Promises the value of this attribute will not be null once it becomes known" +} + +func (m willNotBeNullModifier) PlanModifyFloat32(ctx context.Context, req planmodifier.Float32Request, resp *planmodifier.Float32Response) { + // 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.PlanValue.RefineAsNotNull() +} diff --git a/resource/schema/float32planmodifier/will_not_be_null_test.go b/resource/schema/float32planmodifier/will_not_be_null_test.go new file mode 100644 index 00000000..bab7a0da --- /dev/null +++ b/resource/schema/float32planmodifier/will_not_be_null_test.go @@ -0,0 +1,98 @@ +// 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/types" +) + +func TestWillNotBeNullModifierPlanModifyFloat32(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + request planmodifier.Float32Request + expected *planmodifier.Float32Response + }{ + "known-plan": { + request: planmodifier.Float32Request{ + StateValue: types.Float32Value(5), + PlanValue: types.Float32Value(10), + ConfigValue: types.Float32Null(), + }, + expected: &planmodifier.Float32Response{ + PlanValue: types.Float32Value(10), + }, + }, + "unknown-config": { + // this is the situation in which a user is + // interpolating into a field. We want that to still + // show up as unknown (with no refinement), 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{ + StateValue: types.Float32Value(10), + PlanValue: types.Float32Unknown(), + ConfigValue: types.Float32Unknown(), + }, + expected: &planmodifier.Float32Response{ + PlanValue: types.Float32Unknown(), + }, + }, + "unknown-plan-null-state": { + request: planmodifier.Float32Request{ + StateValue: types.Float32Null(), + PlanValue: types.Float32Unknown(), + ConfigValue: types.Float32Null(), + }, + expected: &planmodifier.Float32Response{ + PlanValue: types.Float32Unknown().RefineAsNotNull(), + }, + }, + "unknown-plan-non-null-state": { + request: planmodifier.Float32Request{ + StateValue: types.Float32Value(10), + PlanValue: types.Float32Unknown(), + ConfigValue: types.Float32Null(), + }, + expected: &planmodifier.Float32Response{ + PlanValue: types.Float32Unknown().RefineAsNotNull(), + }, + }, + "unknown-plan-preserve-existing-refinement": { + request: planmodifier.Float32Request{ + StateValue: types.Float32Null(), + PlanValue: types.Float32Unknown().RefineWithLowerBound(10, false), + ConfigValue: types.Float32Null(), + }, + expected: &planmodifier.Float32Response{ + PlanValue: types.Float32Unknown().RefineAsNotNull().RefineWithLowerBound(10, false), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.Float32Response{ + PlanValue: testCase.request.PlanValue, + } + + float32planmodifier.WillNotBeNull().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/float64planmodifier/will_be_at_least.go b/resource/schema/float64planmodifier/will_be_at_least.go new file mode 100644 index 00000000..2a1bbf94 --- /dev/null +++ b/resource/schema/float64planmodifier/will_be_at_least.go @@ -0,0 +1,50 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package float64planmodifier + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// WillBeAtLeast returns a plan modifier that will add a refinement to an unknown planned value +// which promises that: +// - The final value will not be null. +// - The final value will be greater than or equal to the provided minimum value. +// +// This unknown value refinement allows Terraform to validate more of the configuration during plan +// and evaluate conditional logic in meta-arguments such as "count". +func WillBeAtLeast(minVal float64) planmodifier.Float64 { + return willBeAtLeastModifier{ + min: minVal, + } +} + +type willBeAtLeastModifier struct { + min float64 +} + +func (m willBeAtLeastModifier) Description(_ context.Context) string { + return fmt.Sprintf("Promises the value of this attribute will be at least %f once it becomes known", m.min) +} + +func (m willBeAtLeastModifier) MarkdownDescription(_ context.Context) string { + return fmt.Sprintf("Promises the value of this attribute will be at least %f once it becomes known", m.min) +} + +func (m willBeAtLeastModifier) PlanModifyFloat64(ctx context.Context, req planmodifier.Float64Request, resp *planmodifier.Float64Response) { + // 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.PlanValue.RefineWithLowerBound(m.min, true) +} diff --git a/resource/schema/float64planmodifier/will_be_at_least_test.go b/resource/schema/float64planmodifier/will_be_at_least_test.go new file mode 100644 index 00000000..11f67002 --- /dev/null +++ b/resource/schema/float64planmodifier/will_be_at_least_test.go @@ -0,0 +1,104 @@ +// 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/types" +) + +func TestWillBeAtLeastModifierPlanModifyFloat64(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + minVal float64 + request planmodifier.Float64Request + expected *planmodifier.Float64Response + }{ + "known-plan": { + minVal: 5.5, + request: planmodifier.Float64Request{ + StateValue: types.Float64Value(5), + PlanValue: types.Float64Value(10), + ConfigValue: types.Float64Null(), + }, + expected: &planmodifier.Float64Response{ + PlanValue: types.Float64Value(10), + }, + }, + "unknown-config": { + // this is the situation in which a user is + // interpolating into a field. We want that to still + // show up as unknown (with no refinement), 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 + minVal: 5.5, + request: planmodifier.Float64Request{ + StateValue: types.Float64Value(10), + PlanValue: types.Float64Unknown(), + ConfigValue: types.Float64Unknown(), + }, + expected: &planmodifier.Float64Response{ + PlanValue: types.Float64Unknown(), + }, + }, + "unknown-plan-null-state": { + minVal: 5.5, + request: planmodifier.Float64Request{ + StateValue: types.Float64Null(), + PlanValue: types.Float64Unknown(), + ConfigValue: types.Float64Null(), + }, + expected: &planmodifier.Float64Response{ + PlanValue: types.Float64Unknown().RefineWithLowerBound(5.5, true), + }, + }, + "unknown-plan-non-null-state": { + minVal: 3.5, + request: planmodifier.Float64Request{ + StateValue: types.Float64Value(10), + PlanValue: types.Float64Unknown(), + ConfigValue: types.Float64Null(), + }, + expected: &planmodifier.Float64Response{ + PlanValue: types.Float64Unknown().RefineWithLowerBound(3.5, true), + }, + }, + "unknown-plan-preserve-existing-refinement": { + minVal: 2.5, + request: planmodifier.Float64Request{ + StateValue: types.Float64Null(), + PlanValue: types.Float64Unknown().RefineWithUpperBound(6, false), + ConfigValue: types.Float64Null(), + }, + expected: &planmodifier.Float64Response{ + PlanValue: types.Float64Unknown().RefineWithUpperBound(6, false).RefineWithLowerBound(2.5, true), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.Float64Response{ + PlanValue: testCase.request.PlanValue, + } + + float64planmodifier.WillBeAtLeast(testCase.minVal).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/will_be_at_most.go b/resource/schema/float64planmodifier/will_be_at_most.go new file mode 100644 index 00000000..98222427 --- /dev/null +++ b/resource/schema/float64planmodifier/will_be_at_most.go @@ -0,0 +1,50 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package float64planmodifier + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// WillBeAtMost returns a plan modifier that will add a refinement to an unknown planned value +// which promises that: +// - The final value will not be null. +// - The final value will be less than or equal to the provided maximum value. +// +// This unknown value refinement allows Terraform to validate more of the configuration during plan +// and evaluate conditional logic in meta-arguments such as "count". +func WillBeAtMost(maxVal float64) planmodifier.Float64 { + return willBeAtMostModifier{ + max: maxVal, + } +} + +type willBeAtMostModifier struct { + max float64 +} + +func (m willBeAtMostModifier) Description(_ context.Context) string { + return fmt.Sprintf("Promises the value of this attribute will be at most %f once it becomes known", m.max) +} + +func (m willBeAtMostModifier) MarkdownDescription(_ context.Context) string { + return fmt.Sprintf("Promises the value of this attribute will be at most %f once it becomes known", m.max) +} + +func (m willBeAtMostModifier) PlanModifyFloat64(ctx context.Context, req planmodifier.Float64Request, resp *planmodifier.Float64Response) { + // 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.PlanValue.RefineWithUpperBound(m.max, true) +} diff --git a/resource/schema/float64planmodifier/will_be_at_most_test.go b/resource/schema/float64planmodifier/will_be_at_most_test.go new file mode 100644 index 00000000..76be206b --- /dev/null +++ b/resource/schema/float64planmodifier/will_be_at_most_test.go @@ -0,0 +1,104 @@ +// 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/types" +) + +func TestWillBeAtMostModifierPlanModifyFloat64(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + maxVal float64 + request planmodifier.Float64Request + expected *planmodifier.Float64Response + }{ + "known-plan": { + maxVal: 10.1, + request: planmodifier.Float64Request{ + StateValue: types.Float64Value(5), + PlanValue: types.Float64Value(10), + ConfigValue: types.Float64Null(), + }, + expected: &planmodifier.Float64Response{ + PlanValue: types.Float64Value(10), + }, + }, + "unknown-config": { + // this is the situation in which a user is + // interpolating into a field. We want that to still + // show up as unknown (with no refinement), 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 + maxVal: 10.1, + request: planmodifier.Float64Request{ + StateValue: types.Float64Value(10), + PlanValue: types.Float64Unknown(), + ConfigValue: types.Float64Unknown(), + }, + expected: &planmodifier.Float64Response{ + PlanValue: types.Float64Unknown(), + }, + }, + "unknown-plan-null-state": { + maxVal: 10.1, + request: planmodifier.Float64Request{ + StateValue: types.Float64Null(), + PlanValue: types.Float64Unknown(), + ConfigValue: types.Float64Null(), + }, + expected: &planmodifier.Float64Response{ + PlanValue: types.Float64Unknown().RefineWithUpperBound(10.1, true), + }, + }, + "unknown-plan-non-null-state": { + maxVal: 4.1, + request: planmodifier.Float64Request{ + StateValue: types.Float64Value(10), + PlanValue: types.Float64Unknown(), + ConfigValue: types.Float64Null(), + }, + expected: &planmodifier.Float64Response{ + PlanValue: types.Float64Unknown().RefineWithUpperBound(4.1, true), + }, + }, + "unknown-plan-preserve-existing-refinement": { + maxVal: 6.1, + request: planmodifier.Float64Request{ + StateValue: types.Float64Null(), + PlanValue: types.Float64Unknown().RefineWithLowerBound(2, false), + ConfigValue: types.Float64Null(), + }, + expected: &planmodifier.Float64Response{ + PlanValue: types.Float64Unknown().RefineWithLowerBound(2, false).RefineWithUpperBound(6.1, true), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.Float64Response{ + PlanValue: testCase.request.PlanValue, + } + + float64planmodifier.WillBeAtMost(testCase.maxVal).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/will_be_between.go b/resource/schema/float64planmodifier/will_be_between.go new file mode 100644 index 00000000..f0f06c0a --- /dev/null +++ b/resource/schema/float64planmodifier/will_be_between.go @@ -0,0 +1,55 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package float64planmodifier + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// WillBeBetween returns a plan modifier that will add a refinement to an unknown planned value +// which promises that: +// - The final value will not be null. +// - The final value will be greater than or equal to the provided minimum value. +// - The final value will be less than or equal to the provided maximum value. +// +// This unknown value refinement allows Terraform to validate more of the configuration during plan +// and evaluate conditional logic in meta-arguments such as "count". +func WillBeBetween(minVal, maxVal float64) planmodifier.Float64 { + return willBeBetweenModifier{ + min: minVal, + max: maxVal, + } +} + +type willBeBetweenModifier struct { + min float64 + max float64 +} + +func (m willBeBetweenModifier) Description(_ context.Context) string { + return fmt.Sprintf("Promises the value of this attribute will be between %f and %f once it becomes known", m.min, m.max) +} + +func (m willBeBetweenModifier) MarkdownDescription(_ context.Context) string { + return fmt.Sprintf("Promises the value of this attribute will be between %f and %f once it becomes known", m.min, m.max) +} + +func (m willBeBetweenModifier) PlanModifyFloat64(ctx context.Context, req planmodifier.Float64Request, resp *planmodifier.Float64Response) { + // 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.PlanValue. + RefineWithLowerBound(m.min, true). + RefineWithUpperBound(m.max, true) +} diff --git a/resource/schema/float64planmodifier/will_be_between_test.go b/resource/schema/float64planmodifier/will_be_between_test.go new file mode 100644 index 00000000..7799536f --- /dev/null +++ b/resource/schema/float64planmodifier/will_be_between_test.go @@ -0,0 +1,110 @@ +// 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/types" +) + +func TestWillBeBetweenModifierPlanModifyFloat64(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + minVal float64 + maxVal float64 + request planmodifier.Float64Request + expected *planmodifier.Float64Response + }{ + "known-plan": { + minVal: 5.5, + maxVal: 10.1, + request: planmodifier.Float64Request{ + StateValue: types.Float64Value(5.5), + PlanValue: types.Float64Value(10.1), + ConfigValue: types.Float64Null(), + }, + expected: &planmodifier.Float64Response{ + PlanValue: types.Float64Value(10.1), + }, + }, + "unknown-config": { + // this is the situation in which a user is + // interpolating into a field. We want that to still + // show up as unknown (with no refinement), 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 + minVal: 5.5, + maxVal: 10.1, + request: planmodifier.Float64Request{ + StateValue: types.Float64Value(10.1), + PlanValue: types.Float64Unknown(), + ConfigValue: types.Float64Unknown(), + }, + expected: &planmodifier.Float64Response{ + PlanValue: types.Float64Unknown(), + }, + }, + "unknown-plan-null-state": { + minVal: 5.5, + maxVal: 10.1, + request: planmodifier.Float64Request{ + StateValue: types.Float64Null(), + PlanValue: types.Float64Unknown(), + ConfigValue: types.Float64Null(), + }, + expected: &planmodifier.Float64Response{ + PlanValue: types.Float64Unknown().RefineWithLowerBound(5.5, true).RefineWithUpperBound(10.1, true), + }, + }, + "unknown-plan-non-null-state": { + minVal: 3.5, + maxVal: 4.1, + request: planmodifier.Float64Request{ + StateValue: types.Float64Value(10.1), + PlanValue: types.Float64Unknown(), + ConfigValue: types.Float64Null(), + }, + expected: &planmodifier.Float64Response{ + PlanValue: types.Float64Unknown().RefineWithLowerBound(3.5, true).RefineWithUpperBound(4.1, true), + }, + }, + "unknown-plan-preserve-existing-refinement": { + minVal: 2.5, + maxVal: 6.1, + request: planmodifier.Float64Request{ + StateValue: types.Float64Null(), + PlanValue: types.Float64Unknown().RefineAsNotNull(), + ConfigValue: types.Float64Null(), + }, + expected: &planmodifier.Float64Response{ + PlanValue: types.Float64Unknown().RefineAsNotNull().RefineWithLowerBound(2.5, true).RefineWithUpperBound(6.1, true), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.Float64Response{ + PlanValue: testCase.request.PlanValue, + } + + float64planmodifier.WillBeBetween(testCase.minVal, testCase.maxVal).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/will_not_be_null.go b/resource/schema/float64planmodifier/will_not_be_null.go new file mode 100644 index 00000000..83f9879a --- /dev/null +++ b/resource/schema/float64planmodifier/will_not_be_null.go @@ -0,0 +1,50 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package float64planmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// WillNotBeNull returns a plan modifier that will add a refinement to an unknown planned value +// which promises that the final value will not be null. +// +// This unknown value refinement allows Terraform to validate more of the configuration during plan +// and evaluate conditional logic in meta-arguments such as "count": +// +// resource "examplecloud_thing" "b" { +// // Will successfully evaluate during plan with a "not null" refinement on "float64_attribute" +// count = examplecloud_thing.a.float64_attribute != null ? 1 : 0 +// +// // .. resource config +// } +func WillNotBeNull() planmodifier.Float64 { + return willNotBeNullModifier{} +} + +type willNotBeNullModifier struct{} + +func (m willNotBeNullModifier) Description(_ context.Context) string { + return "Promises the value of this attribute will not be null once it becomes known" +} + +func (m willNotBeNullModifier) MarkdownDescription(_ context.Context) string { + return "Promises the value of this attribute will not be null once it becomes known" +} + +func (m willNotBeNullModifier) PlanModifyFloat64(ctx context.Context, req planmodifier.Float64Request, resp *planmodifier.Float64Response) { + // 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.PlanValue.RefineAsNotNull() +} diff --git a/resource/schema/float64planmodifier/will_not_be_null_test.go b/resource/schema/float64planmodifier/will_not_be_null_test.go new file mode 100644 index 00000000..1461c134 --- /dev/null +++ b/resource/schema/float64planmodifier/will_not_be_null_test.go @@ -0,0 +1,98 @@ +// 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/types" +) + +func TestWillNotBeNullModifierPlanModifyFloat64(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + request planmodifier.Float64Request + expected *planmodifier.Float64Response + }{ + "known-plan": { + request: planmodifier.Float64Request{ + StateValue: types.Float64Value(5), + PlanValue: types.Float64Value(10), + ConfigValue: types.Float64Null(), + }, + expected: &planmodifier.Float64Response{ + PlanValue: types.Float64Value(10), + }, + }, + "unknown-config": { + // this is the situation in which a user is + // interpolating into a field. We want that to still + // show up as unknown (with no refinement), 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{ + StateValue: types.Float64Value(10), + PlanValue: types.Float64Unknown(), + ConfigValue: types.Float64Unknown(), + }, + expected: &planmodifier.Float64Response{ + PlanValue: types.Float64Unknown(), + }, + }, + "unknown-plan-null-state": { + request: planmodifier.Float64Request{ + StateValue: types.Float64Null(), + PlanValue: types.Float64Unknown(), + ConfigValue: types.Float64Null(), + }, + expected: &planmodifier.Float64Response{ + PlanValue: types.Float64Unknown().RefineAsNotNull(), + }, + }, + "unknown-plan-non-null-state": { + request: planmodifier.Float64Request{ + StateValue: types.Float64Value(10), + PlanValue: types.Float64Unknown(), + ConfigValue: types.Float64Null(), + }, + expected: &planmodifier.Float64Response{ + PlanValue: types.Float64Unknown().RefineAsNotNull(), + }, + }, + "unknown-plan-preserve-existing-refinement": { + request: planmodifier.Float64Request{ + StateValue: types.Float64Null(), + PlanValue: types.Float64Unknown().RefineWithLowerBound(10, false), + ConfigValue: types.Float64Null(), + }, + expected: &planmodifier.Float64Response{ + PlanValue: types.Float64Unknown().RefineAsNotNull().RefineWithLowerBound(10, false), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.Float64Response{ + PlanValue: testCase.request.PlanValue, + } + + float64planmodifier.WillNotBeNull().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/int32planmodifier/will_be_at_least.go b/resource/schema/int32planmodifier/will_be_at_least.go new file mode 100644 index 00000000..e2c0e064 --- /dev/null +++ b/resource/schema/int32planmodifier/will_be_at_least.go @@ -0,0 +1,50 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int32planmodifier + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// WillBeAtLeast returns a plan modifier that will add a refinement to an unknown planned value +// which promises that: +// - The final value will not be null. +// - The final value will be greater than or equal to the provided minimum value. +// +// This unknown value refinement allows Terraform to validate more of the configuration during plan +// and evaluate conditional logic in meta-arguments such as "count". +func WillBeAtLeast(minVal int32) planmodifier.Int32 { + return willBeAtLeastModifier{ + min: minVal, + } +} + +type willBeAtLeastModifier struct { + min int32 +} + +func (m willBeAtLeastModifier) Description(_ context.Context) string { + return fmt.Sprintf("Promises the value of this attribute will be at least %d once it becomes known", m.min) +} + +func (m willBeAtLeastModifier) MarkdownDescription(_ context.Context) string { + return fmt.Sprintf("Promises the value of this attribute will be at least %d once it becomes known", m.min) +} + +func (m willBeAtLeastModifier) PlanModifyInt32(ctx context.Context, req planmodifier.Int32Request, resp *planmodifier.Int32Response) { + // 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.PlanValue.RefineWithLowerBound(m.min, true) +} diff --git a/resource/schema/int32planmodifier/will_be_at_least_test.go b/resource/schema/int32planmodifier/will_be_at_least_test.go new file mode 100644 index 00000000..6f1acdca --- /dev/null +++ b/resource/schema/int32planmodifier/will_be_at_least_test.go @@ -0,0 +1,104 @@ +// 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/types" +) + +func TestWillBeAtLeastModifierPlanModifyInt32(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + minVal int32 + request planmodifier.Int32Request + expected *planmodifier.Int32Response + }{ + "known-plan": { + minVal: 5, + request: planmodifier.Int32Request{ + StateValue: types.Int32Value(5), + PlanValue: types.Int32Value(10), + ConfigValue: types.Int32Null(), + }, + expected: &planmodifier.Int32Response{ + PlanValue: types.Int32Value(10), + }, + }, + "unknown-config": { + // this is the situation in which a user is + // interpolating into a field. We want that to still + // show up as unknown (with no refinement), 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 + minVal: 5, + request: planmodifier.Int32Request{ + StateValue: types.Int32Value(10), + PlanValue: types.Int32Unknown(), + ConfigValue: types.Int32Unknown(), + }, + expected: &planmodifier.Int32Response{ + PlanValue: types.Int32Unknown(), + }, + }, + "unknown-plan-null-state": { + minVal: 5, + request: planmodifier.Int32Request{ + StateValue: types.Int32Null(), + PlanValue: types.Int32Unknown(), + ConfigValue: types.Int32Null(), + }, + expected: &planmodifier.Int32Response{ + PlanValue: types.Int32Unknown().RefineWithLowerBound(5, true), + }, + }, + "unknown-plan-non-null-state": { + minVal: 3, + request: planmodifier.Int32Request{ + StateValue: types.Int32Value(10), + PlanValue: types.Int32Unknown(), + ConfigValue: types.Int32Null(), + }, + expected: &planmodifier.Int32Response{ + PlanValue: types.Int32Unknown().RefineWithLowerBound(3, true), + }, + }, + "unknown-plan-preserve-existing-refinement": { + minVal: 2, + request: planmodifier.Int32Request{ + StateValue: types.Int32Null(), + PlanValue: types.Int32Unknown().RefineWithUpperBound(6, false), + ConfigValue: types.Int32Null(), + }, + expected: &planmodifier.Int32Response{ + PlanValue: types.Int32Unknown().RefineWithUpperBound(6, false).RefineWithLowerBound(2, true), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.Int32Response{ + PlanValue: testCase.request.PlanValue, + } + + int32planmodifier.WillBeAtLeast(testCase.minVal).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/will_be_at_most.go b/resource/schema/int32planmodifier/will_be_at_most.go new file mode 100644 index 00000000..d8062edf --- /dev/null +++ b/resource/schema/int32planmodifier/will_be_at_most.go @@ -0,0 +1,50 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int32planmodifier + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// WillBeAtMost returns a plan modifier that will add a refinement to an unknown planned value +// which promises that: +// - The final value will not be null. +// - The final value will be less than or equal to the provided maximum value. +// +// This unknown value refinement allows Terraform to validate more of the configuration during plan +// and evaluate conditional logic in meta-arguments such as "count". +func WillBeAtMost(maxVal int32) planmodifier.Int32 { + return willBeAtMostModifier{ + max: maxVal, + } +} + +type willBeAtMostModifier struct { + max int32 +} + +func (m willBeAtMostModifier) Description(_ context.Context) string { + return fmt.Sprintf("Promises the value of this attribute will be at most %d once it becomes known", m.max) +} + +func (m willBeAtMostModifier) MarkdownDescription(_ context.Context) string { + return fmt.Sprintf("Promises the value of this attribute will be at most %d once it becomes known", m.max) +} + +func (m willBeAtMostModifier) PlanModifyInt32(ctx context.Context, req planmodifier.Int32Request, resp *planmodifier.Int32Response) { + // 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.PlanValue.RefineWithUpperBound(m.max, true) +} diff --git a/resource/schema/int32planmodifier/will_be_at_most_test.go b/resource/schema/int32planmodifier/will_be_at_most_test.go new file mode 100644 index 00000000..9d3b2c18 --- /dev/null +++ b/resource/schema/int32planmodifier/will_be_at_most_test.go @@ -0,0 +1,104 @@ +// 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/types" +) + +func TestWillBeAtMostModifierPlanModifyInt32(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + maxVal int32 + request planmodifier.Int32Request + expected *planmodifier.Int32Response + }{ + "known-plan": { + maxVal: 10, + request: planmodifier.Int32Request{ + StateValue: types.Int32Value(5), + PlanValue: types.Int32Value(10), + ConfigValue: types.Int32Null(), + }, + expected: &planmodifier.Int32Response{ + PlanValue: types.Int32Value(10), + }, + }, + "unknown-config": { + // this is the situation in which a user is + // interpolating into a field. We want that to still + // show up as unknown (with no refinement), 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 + maxVal: 10, + request: planmodifier.Int32Request{ + StateValue: types.Int32Value(10), + PlanValue: types.Int32Unknown(), + ConfigValue: types.Int32Unknown(), + }, + expected: &planmodifier.Int32Response{ + PlanValue: types.Int32Unknown(), + }, + }, + "unknown-plan-null-state": { + maxVal: 10, + request: planmodifier.Int32Request{ + StateValue: types.Int32Null(), + PlanValue: types.Int32Unknown(), + ConfigValue: types.Int32Null(), + }, + expected: &planmodifier.Int32Response{ + PlanValue: types.Int32Unknown().RefineWithUpperBound(10, true), + }, + }, + "unknown-plan-non-null-state": { + maxVal: 4, + request: planmodifier.Int32Request{ + StateValue: types.Int32Value(10), + PlanValue: types.Int32Unknown(), + ConfigValue: types.Int32Null(), + }, + expected: &planmodifier.Int32Response{ + PlanValue: types.Int32Unknown().RefineWithUpperBound(4, true), + }, + }, + "unknown-plan-preserve-existing-refinement": { + maxVal: 6, + request: planmodifier.Int32Request{ + StateValue: types.Int32Null(), + PlanValue: types.Int32Unknown().RefineWithLowerBound(2, false), + ConfigValue: types.Int32Null(), + }, + expected: &planmodifier.Int32Response{ + PlanValue: types.Int32Unknown().RefineWithLowerBound(2, false).RefineWithUpperBound(6, true), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.Int32Response{ + PlanValue: testCase.request.PlanValue, + } + + int32planmodifier.WillBeAtMost(testCase.maxVal).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/will_be_between.go b/resource/schema/int32planmodifier/will_be_between.go new file mode 100644 index 00000000..8355d91f --- /dev/null +++ b/resource/schema/int32planmodifier/will_be_between.go @@ -0,0 +1,55 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int32planmodifier + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// WillBeBetween returns a plan modifier that will add a refinement to an unknown planned value +// which promises that: +// - The final value will not be null. +// - The final value will be greater than or equal to the provided minimum value. +// - The final value will be less than or equal to the provided maximum value. +// +// This unknown value refinement allows Terraform to validate more of the configuration during plan +// and evaluate conditional logic in meta-arguments such as "count". +func WillBeBetween(minVal, maxVal int32) planmodifier.Int32 { + return willBeBetweenModifier{ + min: minVal, + max: maxVal, + } +} + +type willBeBetweenModifier struct { + min int32 + max int32 +} + +func (m willBeBetweenModifier) Description(_ context.Context) string { + return fmt.Sprintf("Promises the value of this attribute will be between %d and %d once it becomes known", m.min, m.max) +} + +func (m willBeBetweenModifier) MarkdownDescription(_ context.Context) string { + return fmt.Sprintf("Promises the value of this attribute will be between %d and %d once it becomes known", m.min, m.max) +} + +func (m willBeBetweenModifier) PlanModifyInt32(ctx context.Context, req planmodifier.Int32Request, resp *planmodifier.Int32Response) { + // 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.PlanValue. + RefineWithLowerBound(m.min, true). + RefineWithUpperBound(m.max, true) +} diff --git a/resource/schema/int32planmodifier/will_be_between_test.go b/resource/schema/int32planmodifier/will_be_between_test.go new file mode 100644 index 00000000..dab0a47d --- /dev/null +++ b/resource/schema/int32planmodifier/will_be_between_test.go @@ -0,0 +1,110 @@ +// 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/types" +) + +func TestWillBeBetweenModifierPlanModifyInt32(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + minVal int32 + maxVal int32 + request planmodifier.Int32Request + expected *planmodifier.Int32Response + }{ + "known-plan": { + minVal: 5, + maxVal: 10, + request: planmodifier.Int32Request{ + StateValue: types.Int32Value(5), + PlanValue: types.Int32Value(10), + ConfigValue: types.Int32Null(), + }, + expected: &planmodifier.Int32Response{ + PlanValue: types.Int32Value(10), + }, + }, + "unknown-config": { + // this is the situation in which a user is + // interpolating into a field. We want that to still + // show up as unknown (with no refinement), 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 + minVal: 5, + maxVal: 10, + request: planmodifier.Int32Request{ + StateValue: types.Int32Value(10), + PlanValue: types.Int32Unknown(), + ConfigValue: types.Int32Unknown(), + }, + expected: &planmodifier.Int32Response{ + PlanValue: types.Int32Unknown(), + }, + }, + "unknown-plan-null-state": { + minVal: 5, + maxVal: 10, + request: planmodifier.Int32Request{ + StateValue: types.Int32Null(), + PlanValue: types.Int32Unknown(), + ConfigValue: types.Int32Null(), + }, + expected: &planmodifier.Int32Response{ + PlanValue: types.Int32Unknown().RefineWithLowerBound(5, true).RefineWithUpperBound(10, true), + }, + }, + "unknown-plan-non-null-state": { + minVal: 3, + maxVal: 4, + request: planmodifier.Int32Request{ + StateValue: types.Int32Value(10), + PlanValue: types.Int32Unknown(), + ConfigValue: types.Int32Null(), + }, + expected: &planmodifier.Int32Response{ + PlanValue: types.Int32Unknown().RefineWithLowerBound(3, true).RefineWithUpperBound(4, true), + }, + }, + "unknown-plan-preserve-existing-refinement": { + minVal: 2, + maxVal: 6, + request: planmodifier.Int32Request{ + StateValue: types.Int32Null(), + PlanValue: types.Int32Unknown().RefineAsNotNull(), + ConfigValue: types.Int32Null(), + }, + expected: &planmodifier.Int32Response{ + PlanValue: types.Int32Unknown().RefineAsNotNull().RefineWithLowerBound(2, true).RefineWithUpperBound(6, true), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.Int32Response{ + PlanValue: testCase.request.PlanValue, + } + + int32planmodifier.WillBeBetween(testCase.minVal, testCase.maxVal).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/will_not_be_null.go b/resource/schema/int32planmodifier/will_not_be_null.go new file mode 100644 index 00000000..aa6a54b5 --- /dev/null +++ b/resource/schema/int32planmodifier/will_not_be_null.go @@ -0,0 +1,50 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int32planmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// WillNotBeNull returns a plan modifier that will add a refinement to an unknown planned value +// which promises that the final value will not be null. +// +// This unknown value refinement allows Terraform to validate more of the configuration during plan +// and evaluate conditional logic in meta-arguments such as "count": +// +// resource "examplecloud_thing" "b" { +// // Will successfully evaluate during plan with a "not null" refinement on "int32_attribute" +// count = examplecloud_thing.a.int32_attribute != null ? 1 : 0 +// +// // .. resource config +// } +func WillNotBeNull() planmodifier.Int32 { + return willNotBeNullModifier{} +} + +type willNotBeNullModifier struct{} + +func (m willNotBeNullModifier) Description(_ context.Context) string { + return "Promises the value of this attribute will not be null once it becomes known" +} + +func (m willNotBeNullModifier) MarkdownDescription(_ context.Context) string { + return "Promises the value of this attribute will not be null once it becomes known" +} + +func (m willNotBeNullModifier) PlanModifyInt32(ctx context.Context, req planmodifier.Int32Request, resp *planmodifier.Int32Response) { + // 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.PlanValue.RefineAsNotNull() +} diff --git a/resource/schema/int32planmodifier/will_not_be_null_test.go b/resource/schema/int32planmodifier/will_not_be_null_test.go new file mode 100644 index 00000000..22a5fa13 --- /dev/null +++ b/resource/schema/int32planmodifier/will_not_be_null_test.go @@ -0,0 +1,98 @@ +// 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/types" +) + +func TestWillNotBeNullModifierPlanModifyInt32(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + request planmodifier.Int32Request + expected *planmodifier.Int32Response + }{ + "known-plan": { + request: planmodifier.Int32Request{ + StateValue: types.Int32Value(5), + PlanValue: types.Int32Value(10), + ConfigValue: types.Int32Null(), + }, + expected: &planmodifier.Int32Response{ + PlanValue: types.Int32Value(10), + }, + }, + "unknown-config": { + // this is the situation in which a user is + // interpolating into a field. We want that to still + // show up as unknown (with no refinement), 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{ + StateValue: types.Int32Value(10), + PlanValue: types.Int32Unknown(), + ConfigValue: types.Int32Unknown(), + }, + expected: &planmodifier.Int32Response{ + PlanValue: types.Int32Unknown(), + }, + }, + "unknown-plan-null-state": { + request: planmodifier.Int32Request{ + StateValue: types.Int32Null(), + PlanValue: types.Int32Unknown(), + ConfigValue: types.Int32Null(), + }, + expected: &planmodifier.Int32Response{ + PlanValue: types.Int32Unknown().RefineAsNotNull(), + }, + }, + "unknown-plan-non-null-state": { + request: planmodifier.Int32Request{ + StateValue: types.Int32Value(10), + PlanValue: types.Int32Unknown(), + ConfigValue: types.Int32Null(), + }, + expected: &planmodifier.Int32Response{ + PlanValue: types.Int32Unknown().RefineAsNotNull(), + }, + }, + "unknown-plan-preserve-existing-refinement": { + request: planmodifier.Int32Request{ + StateValue: types.Int32Null(), + PlanValue: types.Int32Unknown().RefineWithLowerBound(10, false), + ConfigValue: types.Int32Null(), + }, + expected: &planmodifier.Int32Response{ + PlanValue: types.Int32Unknown().RefineAsNotNull().RefineWithLowerBound(10, false), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.Int32Response{ + PlanValue: testCase.request.PlanValue, + } + + int32planmodifier.WillNotBeNull().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/int64planmodifier/will_be_at_least.go b/resource/schema/int64planmodifier/will_be_at_least.go new file mode 100644 index 00000000..6d220e19 --- /dev/null +++ b/resource/schema/int64planmodifier/will_be_at_least.go @@ -0,0 +1,50 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int64planmodifier + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// WillBeAtLeast returns a plan modifier that will add a refinement to an unknown planned value +// which promises that: +// - The final value will not be null. +// - The final value will be greater than or equal to the provided minimum value. +// +// This unknown value refinement allows Terraform to validate more of the configuration during plan +// and evaluate conditional logic in meta-arguments such as "count". +func WillBeAtLeast(minVal int64) planmodifier.Int64 { + return willBeAtLeastModifier{ + min: minVal, + } +} + +type willBeAtLeastModifier struct { + min int64 +} + +func (m willBeAtLeastModifier) Description(_ context.Context) string { + return fmt.Sprintf("Promises the value of this attribute will be at least %d once it becomes known", m.min) +} + +func (m willBeAtLeastModifier) MarkdownDescription(_ context.Context) string { + return fmt.Sprintf("Promises the value of this attribute will be at least %d once it becomes known", m.min) +} + +func (m willBeAtLeastModifier) PlanModifyInt64(ctx context.Context, req planmodifier.Int64Request, resp *planmodifier.Int64Response) { + // 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.PlanValue.RefineWithLowerBound(m.min, true) +} diff --git a/resource/schema/int64planmodifier/will_be_at_least_test.go b/resource/schema/int64planmodifier/will_be_at_least_test.go new file mode 100644 index 00000000..a2aa34d1 --- /dev/null +++ b/resource/schema/int64planmodifier/will_be_at_least_test.go @@ -0,0 +1,104 @@ +// 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/types" +) + +func TestWillBeAtLeastModifierPlanModifyInt64(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + minVal int64 + request planmodifier.Int64Request + expected *planmodifier.Int64Response + }{ + "known-plan": { + minVal: 5, + request: planmodifier.Int64Request{ + StateValue: types.Int64Value(5), + PlanValue: types.Int64Value(10), + ConfigValue: types.Int64Null(), + }, + expected: &planmodifier.Int64Response{ + PlanValue: types.Int64Value(10), + }, + }, + "unknown-config": { + // this is the situation in which a user is + // interpolating into a field. We want that to still + // show up as unknown (with no refinement), 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 + minVal: 5, + request: planmodifier.Int64Request{ + StateValue: types.Int64Value(10), + PlanValue: types.Int64Unknown(), + ConfigValue: types.Int64Unknown(), + }, + expected: &planmodifier.Int64Response{ + PlanValue: types.Int64Unknown(), + }, + }, + "unknown-plan-null-state": { + minVal: 5, + request: planmodifier.Int64Request{ + StateValue: types.Int64Null(), + PlanValue: types.Int64Unknown(), + ConfigValue: types.Int64Null(), + }, + expected: &planmodifier.Int64Response{ + PlanValue: types.Int64Unknown().RefineWithLowerBound(5, true), + }, + }, + "unknown-plan-non-null-state": { + minVal: 3, + request: planmodifier.Int64Request{ + StateValue: types.Int64Value(10), + PlanValue: types.Int64Unknown(), + ConfigValue: types.Int64Null(), + }, + expected: &planmodifier.Int64Response{ + PlanValue: types.Int64Unknown().RefineWithLowerBound(3, true), + }, + }, + "unknown-plan-preserve-existing-refinement": { + minVal: 2, + request: planmodifier.Int64Request{ + StateValue: types.Int64Null(), + PlanValue: types.Int64Unknown().RefineWithUpperBound(6, false), + ConfigValue: types.Int64Null(), + }, + expected: &planmodifier.Int64Response{ + PlanValue: types.Int64Unknown().RefineWithUpperBound(6, false).RefineWithLowerBound(2, true), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.Int64Response{ + PlanValue: testCase.request.PlanValue, + } + + int64planmodifier.WillBeAtLeast(testCase.minVal).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/will_be_at_most.go b/resource/schema/int64planmodifier/will_be_at_most.go new file mode 100644 index 00000000..1064ab89 --- /dev/null +++ b/resource/schema/int64planmodifier/will_be_at_most.go @@ -0,0 +1,50 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int64planmodifier + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// WillBeAtMost returns a plan modifier that will add a refinement to an unknown planned value +// which promises that: +// - The final value will not be null. +// - The final value will be less than or equal to the provided maximum value. +// +// This unknown value refinement allows Terraform to validate more of the configuration during plan +// and evaluate conditional logic in meta-arguments such as "count". +func WillBeAtMost(maxVal int64) planmodifier.Int64 { + return willBeAtMostModifier{ + max: maxVal, + } +} + +type willBeAtMostModifier struct { + max int64 +} + +func (m willBeAtMostModifier) Description(_ context.Context) string { + return fmt.Sprintf("Promises the value of this attribute will be at most %d once it becomes known", m.max) +} + +func (m willBeAtMostModifier) MarkdownDescription(_ context.Context) string { + return fmt.Sprintf("Promises the value of this attribute will be at most %d once it becomes known", m.max) +} + +func (m willBeAtMostModifier) PlanModifyInt64(ctx context.Context, req planmodifier.Int64Request, resp *planmodifier.Int64Response) { + // 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.PlanValue.RefineWithUpperBound(m.max, true) +} diff --git a/resource/schema/int64planmodifier/will_be_at_most_test.go b/resource/schema/int64planmodifier/will_be_at_most_test.go new file mode 100644 index 00000000..9190d901 --- /dev/null +++ b/resource/schema/int64planmodifier/will_be_at_most_test.go @@ -0,0 +1,104 @@ +// 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/types" +) + +func TestWillBeAtMostModifierPlanModifyInt64(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + maxVal int64 + request planmodifier.Int64Request + expected *planmodifier.Int64Response + }{ + "known-plan": { + maxVal: 10, + request: planmodifier.Int64Request{ + StateValue: types.Int64Value(5), + PlanValue: types.Int64Value(10), + ConfigValue: types.Int64Null(), + }, + expected: &planmodifier.Int64Response{ + PlanValue: types.Int64Value(10), + }, + }, + "unknown-config": { + // this is the situation in which a user is + // interpolating into a field. We want that to still + // show up as unknown (with no refinement), 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 + maxVal: 10, + request: planmodifier.Int64Request{ + StateValue: types.Int64Value(10), + PlanValue: types.Int64Unknown(), + ConfigValue: types.Int64Unknown(), + }, + expected: &planmodifier.Int64Response{ + PlanValue: types.Int64Unknown(), + }, + }, + "unknown-plan-null-state": { + maxVal: 10, + request: planmodifier.Int64Request{ + StateValue: types.Int64Null(), + PlanValue: types.Int64Unknown(), + ConfigValue: types.Int64Null(), + }, + expected: &planmodifier.Int64Response{ + PlanValue: types.Int64Unknown().RefineWithUpperBound(10, true), + }, + }, + "unknown-plan-non-null-state": { + maxVal: 4, + request: planmodifier.Int64Request{ + StateValue: types.Int64Value(10), + PlanValue: types.Int64Unknown(), + ConfigValue: types.Int64Null(), + }, + expected: &planmodifier.Int64Response{ + PlanValue: types.Int64Unknown().RefineWithUpperBound(4, true), + }, + }, + "unknown-plan-preserve-existing-refinement": { + maxVal: 6, + request: planmodifier.Int64Request{ + StateValue: types.Int64Null(), + PlanValue: types.Int64Unknown().RefineWithLowerBound(2, false), + ConfigValue: types.Int64Null(), + }, + expected: &planmodifier.Int64Response{ + PlanValue: types.Int64Unknown().RefineWithLowerBound(2, false).RefineWithUpperBound(6, true), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.Int64Response{ + PlanValue: testCase.request.PlanValue, + } + + int64planmodifier.WillBeAtMost(testCase.maxVal).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/will_be_between.go b/resource/schema/int64planmodifier/will_be_between.go new file mode 100644 index 00000000..6bb5f564 --- /dev/null +++ b/resource/schema/int64planmodifier/will_be_between.go @@ -0,0 +1,55 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int64planmodifier + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// WillBeBetween returns a plan modifier that will add a refinement to an unknown planned value +// which promises that: +// - The final value will not be null. +// - The final value will be greater than or equal to the provided minimum value. +// - The final value will be less than or equal to the provided maximum value. +// +// This unknown value refinement allows Terraform to validate more of the configuration during plan +// and evaluate conditional logic in meta-arguments such as "count". +func WillBeBetween(minVal, maxVal int64) planmodifier.Int64 { + return willBeBetweenModifier{ + min: minVal, + max: maxVal, + } +} + +type willBeBetweenModifier struct { + min int64 + max int64 +} + +func (m willBeBetweenModifier) Description(_ context.Context) string { + return fmt.Sprintf("Promises the value of this attribute will be between %d and %d once it becomes known", m.min, m.max) +} + +func (m willBeBetweenModifier) MarkdownDescription(_ context.Context) string { + return fmt.Sprintf("Promises the value of this attribute will be between %d and %d once it becomes known", m.min, m.max) +} + +func (m willBeBetweenModifier) PlanModifyInt64(ctx context.Context, req planmodifier.Int64Request, resp *planmodifier.Int64Response) { + // 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.PlanValue. + RefineWithLowerBound(m.min, true). + RefineWithUpperBound(m.max, true) +} diff --git a/resource/schema/int64planmodifier/will_be_between_test.go b/resource/schema/int64planmodifier/will_be_between_test.go new file mode 100644 index 00000000..8a9afb81 --- /dev/null +++ b/resource/schema/int64planmodifier/will_be_between_test.go @@ -0,0 +1,110 @@ +// 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/types" +) + +func TestWillBeBetweenModifierPlanModifyInt64(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + minVal int64 + maxVal int64 + request planmodifier.Int64Request + expected *planmodifier.Int64Response + }{ + "known-plan": { + minVal: 5, + maxVal: 10, + request: planmodifier.Int64Request{ + StateValue: types.Int64Value(5), + PlanValue: types.Int64Value(10), + ConfigValue: types.Int64Null(), + }, + expected: &planmodifier.Int64Response{ + PlanValue: types.Int64Value(10), + }, + }, + "unknown-config": { + // this is the situation in which a user is + // interpolating into a field. We want that to still + // show up as unknown (with no refinement), 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 + minVal: 5, + maxVal: 10, + request: planmodifier.Int64Request{ + StateValue: types.Int64Value(10), + PlanValue: types.Int64Unknown(), + ConfigValue: types.Int64Unknown(), + }, + expected: &planmodifier.Int64Response{ + PlanValue: types.Int64Unknown(), + }, + }, + "unknown-plan-null-state": { + minVal: 5, + maxVal: 10, + request: planmodifier.Int64Request{ + StateValue: types.Int64Null(), + PlanValue: types.Int64Unknown(), + ConfigValue: types.Int64Null(), + }, + expected: &planmodifier.Int64Response{ + PlanValue: types.Int64Unknown().RefineWithLowerBound(5, true).RefineWithUpperBound(10, true), + }, + }, + "unknown-plan-non-null-state": { + minVal: 3, + maxVal: 4, + request: planmodifier.Int64Request{ + StateValue: types.Int64Value(10), + PlanValue: types.Int64Unknown(), + ConfigValue: types.Int64Null(), + }, + expected: &planmodifier.Int64Response{ + PlanValue: types.Int64Unknown().RefineWithLowerBound(3, true).RefineWithUpperBound(4, true), + }, + }, + "unknown-plan-preserve-existing-refinement": { + minVal: 2, + maxVal: 6, + request: planmodifier.Int64Request{ + StateValue: types.Int64Null(), + PlanValue: types.Int64Unknown().RefineAsNotNull(), + ConfigValue: types.Int64Null(), + }, + expected: &planmodifier.Int64Response{ + PlanValue: types.Int64Unknown().RefineAsNotNull().RefineWithLowerBound(2, true).RefineWithUpperBound(6, true), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.Int64Response{ + PlanValue: testCase.request.PlanValue, + } + + int64planmodifier.WillBeBetween(testCase.minVal, testCase.maxVal).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/will_not_be_null.go b/resource/schema/int64planmodifier/will_not_be_null.go new file mode 100644 index 00000000..7818554e --- /dev/null +++ b/resource/schema/int64planmodifier/will_not_be_null.go @@ -0,0 +1,50 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package int64planmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// WillNotBeNull returns a plan modifier that will add a refinement to an unknown planned value +// which promises that the final value will not be null. +// +// This unknown value refinement allows Terraform to validate more of the configuration during plan +// and evaluate conditional logic in meta-arguments such as "count": +// +// resource "examplecloud_thing" "b" { +// // Will successfully evaluate during plan with a "not null" refinement on "int64_attribute" +// count = examplecloud_thing.a.int64_attribute != null ? 1 : 0 +// +// // .. resource config +// } +func WillNotBeNull() planmodifier.Int64 { + return willNotBeNullModifier{} +} + +type willNotBeNullModifier struct{} + +func (m willNotBeNullModifier) Description(_ context.Context) string { + return "Promises the value of this attribute will not be null once it becomes known" +} + +func (m willNotBeNullModifier) MarkdownDescription(_ context.Context) string { + return "Promises the value of this attribute will not be null once it becomes known" +} + +func (m willNotBeNullModifier) PlanModifyInt64(ctx context.Context, req planmodifier.Int64Request, resp *planmodifier.Int64Response) { + // 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.PlanValue.RefineAsNotNull() +} diff --git a/resource/schema/int64planmodifier/will_not_be_null_test.go b/resource/schema/int64planmodifier/will_not_be_null_test.go new file mode 100644 index 00000000..8d9fe1a6 --- /dev/null +++ b/resource/schema/int64planmodifier/will_not_be_null_test.go @@ -0,0 +1,98 @@ +// 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/types" +) + +func TestWillNotBeNullModifierPlanModifyInt64(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + request planmodifier.Int64Request + expected *planmodifier.Int64Response + }{ + "known-plan": { + request: planmodifier.Int64Request{ + StateValue: types.Int64Value(5), + PlanValue: types.Int64Value(10), + ConfigValue: types.Int64Null(), + }, + expected: &planmodifier.Int64Response{ + PlanValue: types.Int64Value(10), + }, + }, + "unknown-config": { + // this is the situation in which a user is + // interpolating into a field. We want that to still + // show up as unknown (with no refinement), 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{ + StateValue: types.Int64Value(10), + PlanValue: types.Int64Unknown(), + ConfigValue: types.Int64Unknown(), + }, + expected: &planmodifier.Int64Response{ + PlanValue: types.Int64Unknown(), + }, + }, + "unknown-plan-null-state": { + request: planmodifier.Int64Request{ + StateValue: types.Int64Null(), + PlanValue: types.Int64Unknown(), + ConfigValue: types.Int64Null(), + }, + expected: &planmodifier.Int64Response{ + PlanValue: types.Int64Unknown().RefineAsNotNull(), + }, + }, + "unknown-plan-non-null-state": { + request: planmodifier.Int64Request{ + StateValue: types.Int64Value(10), + PlanValue: types.Int64Unknown(), + ConfigValue: types.Int64Null(), + }, + expected: &planmodifier.Int64Response{ + PlanValue: types.Int64Unknown().RefineAsNotNull(), + }, + }, + "unknown-plan-preserve-existing-refinement": { + request: planmodifier.Int64Request{ + StateValue: types.Int64Null(), + PlanValue: types.Int64Unknown().RefineWithLowerBound(10, false), + ConfigValue: types.Int64Null(), + }, + expected: &planmodifier.Int64Response{ + PlanValue: types.Int64Unknown().RefineAsNotNull().RefineWithLowerBound(10, false), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.Int64Response{ + PlanValue: testCase.request.PlanValue, + } + + int64planmodifier.WillNotBeNull().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/listplanmodifier/will_have_size_at_least.go b/resource/schema/listplanmodifier/will_have_size_at_least.go new file mode 100644 index 00000000..538dd79a --- /dev/null +++ b/resource/schema/listplanmodifier/will_have_size_at_least.go @@ -0,0 +1,50 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package listplanmodifier + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// WillHaveSizeAtLeast returns a plan modifier that will add a refinement to an unknown planned value +// which promises that: +// - The final value will not be null. +// - The final size of the list value will be at least the provided minimum value. +// +// This unknown value refinement allows Terraform to validate more of the configuration during plan +// and evaluate conditional logic in meta-arguments such as "count". +func WillHaveSizeAtLeast(minVal int) planmodifier.List { + return willHaveSizeAtLeastModifier{ + min: minVal, + } +} + +type willHaveSizeAtLeastModifier struct { + min int +} + +func (m willHaveSizeAtLeastModifier) Description(_ context.Context) string { + return fmt.Sprintf("Promises the value of this attribute will contain at least %d elements once it becomes known", m.min) +} + +func (m willHaveSizeAtLeastModifier) MarkdownDescription(_ context.Context) string { + return fmt.Sprintf("Promises the value of this attribute will contain at least %d elements once it becomes known", m.min) +} + +func (m willHaveSizeAtLeastModifier) PlanModifyList(ctx context.Context, req planmodifier.ListRequest, resp *planmodifier.ListResponse) { + // 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.PlanValue.RefineWithLengthLowerBound(int64(m.min)) +} diff --git a/resource/schema/listplanmodifier/will_have_size_at_least_test.go b/resource/schema/listplanmodifier/will_have_size_at_least_test.go new file mode 100644 index 00000000..9ae36696 --- /dev/null +++ b/resource/schema/listplanmodifier/will_have_size_at_least_test.go @@ -0,0 +1,105 @@ +// 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/types" +) + +func TestWillHaveSizeAtLeastModifierPlanModifyList(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + minVal int + request planmodifier.ListRequest + expected *planmodifier.ListResponse + }{ + "known-plan": { + minVal: 5, + request: planmodifier.ListRequest{ + StateValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("hello"), types.StringValue("world")}), + PlanValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("hello"), types.StringValue("world"), types.StringValue("!")}), + ConfigValue: types.ListNull(types.StringType), + }, + expected: &planmodifier.ListResponse{ + PlanValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("hello"), types.StringValue("world"), types.StringValue("!")}), + }, + }, + "unknown-config": { + // this is the situation in which a user is + // interpolating into a field. We want that to still + // show up as unknown (with no refinement), 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 + minVal: 5, + request: planmodifier.ListRequest{ + StateValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("hello"), types.StringValue("world"), types.StringValue("!")}), + PlanValue: types.ListUnknown(types.StringType), + ConfigValue: types.ListUnknown(types.StringType), + }, + expected: &planmodifier.ListResponse{ + PlanValue: types.ListUnknown(types.StringType), + }, + }, + "unknown-plan-null-state": { + minVal: 5, + request: planmodifier.ListRequest{ + StateValue: types.ListNull(types.StringType), + PlanValue: types.ListUnknown(types.StringType), + ConfigValue: types.ListNull(types.StringType), + }, + expected: &planmodifier.ListResponse{ + PlanValue: types.ListUnknown(types.StringType).RefineWithLengthLowerBound(5), + }, + }, + "unknown-plan-non-null-state": { + minVal: 3, + request: planmodifier.ListRequest{ + StateValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("hello"), types.StringValue("world"), types.StringValue("!")}), + PlanValue: types.ListUnknown(types.StringType), + ConfigValue: types.ListNull(types.StringType), + }, + expected: &planmodifier.ListResponse{ + PlanValue: types.ListUnknown(types.StringType).RefineWithLengthLowerBound(3), + }, + }, + "unknown-plan-preserve-existing-refinement": { + minVal: 2, + request: planmodifier.ListRequest{ + StateValue: types.ListNull(types.StringType), + PlanValue: types.ListUnknown(types.StringType).RefineWithLengthUpperBound(6), + ConfigValue: types.ListNull(types.StringType), + }, + expected: &planmodifier.ListResponse{ + PlanValue: types.ListUnknown(types.StringType).RefineWithLengthUpperBound(6).RefineWithLengthLowerBound(2), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.ListResponse{ + PlanValue: testCase.request.PlanValue, + } + + listplanmodifier.WillHaveSizeAtLeast(testCase.minVal).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/will_have_size_at_most.go b/resource/schema/listplanmodifier/will_have_size_at_most.go new file mode 100644 index 00000000..49326c6b --- /dev/null +++ b/resource/schema/listplanmodifier/will_have_size_at_most.go @@ -0,0 +1,50 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package listplanmodifier + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// WillHaveSizeAtMost returns a plan modifier that will add a refinement to an unknown planned value +// which promises that: +// - The final value will not be null. +// - The final size of the list value will be at most the provided maximum value. +// +// This unknown value refinement allows Terraform to validate more of the configuration during plan +// and evaluate conditional logic in meta-arguments such as "count". +func WillHaveSizeAtMost(maxVal int) planmodifier.List { + return willHaveSizeAtMostModifier{ + max: maxVal, + } +} + +type willHaveSizeAtMostModifier struct { + max int +} + +func (m willHaveSizeAtMostModifier) Description(_ context.Context) string { + return fmt.Sprintf("Promises the value of this attribute will contain at most %d elements once it becomes known", m.max) +} + +func (m willHaveSizeAtMostModifier) MarkdownDescription(_ context.Context) string { + return fmt.Sprintf("Promises the value of this attribute will contain at most %d elements once it becomes known", m.max) +} + +func (m willHaveSizeAtMostModifier) PlanModifyList(ctx context.Context, req planmodifier.ListRequest, resp *planmodifier.ListResponse) { + // 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.PlanValue.RefineWithLengthUpperBound(int64(m.max)) +} diff --git a/resource/schema/listplanmodifier/will_have_size_at_most_test.go b/resource/schema/listplanmodifier/will_have_size_at_most_test.go new file mode 100644 index 00000000..9798568e --- /dev/null +++ b/resource/schema/listplanmodifier/will_have_size_at_most_test.go @@ -0,0 +1,105 @@ +// 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/types" +) + +func TestWillHaveSizeAtMostModifierPlanModifyList(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + maxVal int + request planmodifier.ListRequest + expected *planmodifier.ListResponse + }{ + "known-plan": { + maxVal: 10, + request: planmodifier.ListRequest{ + StateValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("hello"), types.StringValue("world")}), + PlanValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("hello"), types.StringValue("world"), types.StringValue("!")}), + ConfigValue: types.ListNull(types.StringType), + }, + expected: &planmodifier.ListResponse{ + PlanValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("hello"), types.StringValue("world"), types.StringValue("!")}), + }, + }, + "unknown-config": { + // this is the situation in which a user is + // interpolating into a field. We want that to still + // show up as unknown (with no refinement), 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 + maxVal: 10, + request: planmodifier.ListRequest{ + StateValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("hello"), types.StringValue("world"), types.StringValue("!")}), + PlanValue: types.ListUnknown(types.StringType), + ConfigValue: types.ListUnknown(types.StringType), + }, + expected: &planmodifier.ListResponse{ + PlanValue: types.ListUnknown(types.StringType), + }, + }, + "unknown-plan-null-state": { + maxVal: 10, + request: planmodifier.ListRequest{ + StateValue: types.ListNull(types.StringType), + PlanValue: types.ListUnknown(types.StringType), + ConfigValue: types.ListNull(types.StringType), + }, + expected: &planmodifier.ListResponse{ + PlanValue: types.ListUnknown(types.StringType).RefineWithLengthUpperBound(10), + }, + }, + "unknown-plan-non-null-state": { + maxVal: 4, + request: planmodifier.ListRequest{ + StateValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("hello"), types.StringValue("world"), types.StringValue("!")}), + PlanValue: types.ListUnknown(types.StringType), + ConfigValue: types.ListNull(types.StringType), + }, + expected: &planmodifier.ListResponse{ + PlanValue: types.ListUnknown(types.StringType).RefineWithLengthUpperBound(4), + }, + }, + "unknown-plan-preserve-existing-refinement": { + maxVal: 6, + request: planmodifier.ListRequest{ + StateValue: types.ListNull(types.StringType), + PlanValue: types.ListUnknown(types.StringType).RefineWithLengthLowerBound(2), + ConfigValue: types.ListNull(types.StringType), + }, + expected: &planmodifier.ListResponse{ + PlanValue: types.ListUnknown(types.StringType).RefineWithLengthLowerBound(2).RefineWithLengthUpperBound(6), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.ListResponse{ + PlanValue: testCase.request.PlanValue, + } + + listplanmodifier.WillHaveSizeAtMost(testCase.maxVal).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/will_have_size_between.go b/resource/schema/listplanmodifier/will_have_size_between.go new file mode 100644 index 00000000..8514fa46 --- /dev/null +++ b/resource/schema/listplanmodifier/will_have_size_between.go @@ -0,0 +1,55 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package listplanmodifier + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// WillHaveSizeBetween returns a plan modifier that will add a refinement to an unknown planned value +// which promises that: +// - The final value will not be null. +// - The final size of the list value will be at least the provided minimum value. +// - The final size of the list value will be at most the provided maximum value. +// +// This unknown value refinement allows Terraform to validate more of the configuration during plan +// and evaluate conditional logic in meta-arguments such as "count". +func WillHaveSizeBetween(minVal, maxVal int) planmodifier.List { + return willHaveSizeBetweenModifier{ + min: minVal, + max: maxVal, + } +} + +type willHaveSizeBetweenModifier struct { + min int + max int +} + +func (m willHaveSizeBetweenModifier) Description(_ context.Context) string { + return fmt.Sprintf("Promises the value of this attribute will contain at least %d elements and at most %d elements once it becomes known", m.min, m.max) +} + +func (m willHaveSizeBetweenModifier) MarkdownDescription(_ context.Context) string { + return fmt.Sprintf("Promises the value of this attribute will contain at least %d elements and at most %d elements once it becomes known", m.min, m.max) +} + +func (m willHaveSizeBetweenModifier) PlanModifyList(ctx context.Context, req planmodifier.ListRequest, resp *planmodifier.ListResponse) { + // 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.PlanValue. + RefineWithLengthLowerBound(int64(m.min)). + RefineWithLengthUpperBound(int64(m.max)) +} diff --git a/resource/schema/listplanmodifier/will_have_size_between_test.go b/resource/schema/listplanmodifier/will_have_size_between_test.go new file mode 100644 index 00000000..1a6b03d1 --- /dev/null +++ b/resource/schema/listplanmodifier/will_have_size_between_test.go @@ -0,0 +1,111 @@ +// 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/types" +) + +func TestWillHaveSizeBetweenModifierPlanModifyList(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + minVal int + maxVal int + request planmodifier.ListRequest + expected *planmodifier.ListResponse + }{ + "known-plan": { + minVal: 5, + maxVal: 10, + request: planmodifier.ListRequest{ + StateValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("hello"), types.StringValue("world")}), + PlanValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("hello"), types.StringValue("world"), types.StringValue("!")}), + ConfigValue: types.ListNull(types.StringType), + }, + expected: &planmodifier.ListResponse{ + PlanValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("hello"), types.StringValue("world"), types.StringValue("!")}), + }, + }, + "unknown-config": { + // this is the situation in which a user is + // interpolating into a field. We want that to still + // show up as unknown (with no refinement), 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 + minVal: 5, + maxVal: 10, + request: planmodifier.ListRequest{ + StateValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("hello"), types.StringValue("world"), types.StringValue("!")}), + PlanValue: types.ListUnknown(types.StringType), + ConfigValue: types.ListUnknown(types.StringType), + }, + expected: &planmodifier.ListResponse{ + PlanValue: types.ListUnknown(types.StringType), + }, + }, + "unknown-plan-null-state": { + minVal: 5, + maxVal: 10, + request: planmodifier.ListRequest{ + StateValue: types.ListNull(types.StringType), + PlanValue: types.ListUnknown(types.StringType), + ConfigValue: types.ListNull(types.StringType), + }, + expected: &planmodifier.ListResponse{ + PlanValue: types.ListUnknown(types.StringType).RefineWithLengthLowerBound(5).RefineWithLengthUpperBound(10), + }, + }, + "unknown-plan-non-null-state": { + minVal: 3, + maxVal: 4, + request: planmodifier.ListRequest{ + StateValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("hello"), types.StringValue("world"), types.StringValue("!")}), + PlanValue: types.ListUnknown(types.StringType), + ConfigValue: types.ListNull(types.StringType), + }, + expected: &planmodifier.ListResponse{ + PlanValue: types.ListUnknown(types.StringType).RefineWithLengthLowerBound(3).RefineWithLengthUpperBound(4), + }, + }, + "unknown-plan-preserve-existing-refinement": { + minVal: 2, + maxVal: 6, + request: planmodifier.ListRequest{ + StateValue: types.ListNull(types.StringType), + PlanValue: types.ListUnknown(types.StringType).RefineAsNotNull(), + ConfigValue: types.ListNull(types.StringType), + }, + expected: &planmodifier.ListResponse{ + PlanValue: types.ListUnknown(types.StringType).RefineAsNotNull().RefineWithLengthLowerBound(2).RefineWithLengthUpperBound(6), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.ListResponse{ + PlanValue: testCase.request.PlanValue, + } + + listplanmodifier.WillHaveSizeBetween(testCase.minVal, testCase.maxVal).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/will_not_be_null.go b/resource/schema/listplanmodifier/will_not_be_null.go new file mode 100644 index 00000000..89b27c8b --- /dev/null +++ b/resource/schema/listplanmodifier/will_not_be_null.go @@ -0,0 +1,50 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package listplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// WillNotBeNull returns a plan modifier that will add a refinement to an unknown planned value +// which promises that the final value will not be null. +// +// This unknown value refinement allows Terraform to validate more of the configuration during plan +// and evaluate conditional logic in meta-arguments such as "count": +// +// resource "examplecloud_thing" "b" { +// // Will successfully evaluate during plan with a "not null" refinement on "list_attribute" +// count = examplecloud_thing.a.list_attribute != null ? 1 : 0 +// +// // .. resource config +// } +func WillNotBeNull() planmodifier.List { + return willNotBeNullModifier{} +} + +type willNotBeNullModifier struct{} + +func (m willNotBeNullModifier) Description(_ context.Context) string { + return "Promises the value of this attribute will not be null once it becomes known" +} + +func (m willNotBeNullModifier) MarkdownDescription(_ context.Context) string { + return "Promises the value of this attribute will not be null once it becomes known" +} + +func (m willNotBeNullModifier) PlanModifyList(ctx context.Context, req planmodifier.ListRequest, resp *planmodifier.ListResponse) { + // 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.PlanValue.RefineAsNotNull() +} diff --git a/resource/schema/listplanmodifier/will_not_be_null_test.go b/resource/schema/listplanmodifier/will_not_be_null_test.go new file mode 100644 index 00000000..15e0812d --- /dev/null +++ b/resource/schema/listplanmodifier/will_not_be_null_test.go @@ -0,0 +1,99 @@ +// 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/types" +) + +func TestWillNotBeNullModifierPlanModifyList(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + request planmodifier.ListRequest + expected *planmodifier.ListResponse + }{ + "known-plan": { + request: planmodifier.ListRequest{ + StateValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("hello"), types.StringValue("world")}), + PlanValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("hello"), types.StringValue("world"), types.StringValue("!")}), + ConfigValue: types.ListNull(types.StringType), + }, + expected: &planmodifier.ListResponse{ + PlanValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("hello"), types.StringValue("world"), types.StringValue("!")}), + }, + }, + "unknown-config": { + // this is the situation in which a user is + // interpolating into a field. We want that to still + // show up as unknown (with no refinement), 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("hello"), types.StringValue("world"), types.StringValue("!")}), + PlanValue: types.ListUnknown(types.StringType), + ConfigValue: types.ListUnknown(types.StringType), + }, + expected: &planmodifier.ListResponse{ + PlanValue: types.ListUnknown(types.StringType), + }, + }, + "unknown-plan-null-state": { + request: planmodifier.ListRequest{ + StateValue: types.ListNull(types.StringType), + PlanValue: types.ListUnknown(types.StringType), + ConfigValue: types.ListNull(types.StringType), + }, + expected: &planmodifier.ListResponse{ + PlanValue: types.ListUnknown(types.StringType).RefineAsNotNull(), + }, + }, + "unknown-plan-non-null-state": { + request: planmodifier.ListRequest{ + StateValue: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("hello"), types.StringValue("world"), types.StringValue("!")}), + PlanValue: types.ListUnknown(types.StringType), + ConfigValue: types.ListNull(types.StringType), + }, + expected: &planmodifier.ListResponse{ + PlanValue: types.ListUnknown(types.StringType).RefineAsNotNull(), + }, + }, + "unknown-plan-preserve-existing-refinement": { + request: planmodifier.ListRequest{ + StateValue: types.ListNull(types.StringType), + PlanValue: types.ListUnknown(types.StringType).RefineWithLengthLowerBound(10), + ConfigValue: types.ListNull(types.StringType), + }, + expected: &planmodifier.ListResponse{ + PlanValue: types.ListUnknown(types.StringType).RefineAsNotNull().RefineWithLengthLowerBound(10), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.ListResponse{ + PlanValue: testCase.request.PlanValue, + } + + listplanmodifier.WillNotBeNull().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/mapplanmodifier/will_have_size_at_least.go b/resource/schema/mapplanmodifier/will_have_size_at_least.go new file mode 100644 index 00000000..fb009fad --- /dev/null +++ b/resource/schema/mapplanmodifier/will_have_size_at_least.go @@ -0,0 +1,50 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package mapplanmodifier + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// WillHaveSizeAtLeast returns a plan modifier that will add a refinement to an unknown planned value +// which promises that: +// - The final value will not be null. +// - The final size of the map value will be at least the provided minimum value. +// +// This unknown value refinement allows Terraform to validate more of the configuration during plan +// and evaluate conditional logic in meta-arguments such as "count". +func WillHaveSizeAtLeast(minVal int) planmodifier.Map { + return willHaveSizeAtLeastModifier{ + min: minVal, + } +} + +type willHaveSizeAtLeastModifier struct { + min int +} + +func (m willHaveSizeAtLeastModifier) Description(_ context.Context) string { + return fmt.Sprintf("Promises the value of this attribute will contain at least %d elements once it becomes known", m.min) +} + +func (m willHaveSizeAtLeastModifier) MarkdownDescription(_ context.Context) string { + return fmt.Sprintf("Promises the value of this attribute will contain at least %d elements once it becomes known", m.min) +} + +func (m willHaveSizeAtLeastModifier) PlanModifyMap(ctx context.Context, req planmodifier.MapRequest, resp *planmodifier.MapResponse) { + // 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.PlanValue.RefineWithLengthLowerBound(int64(m.min)) +} diff --git a/resource/schema/mapplanmodifier/will_have_size_at_least_test.go b/resource/schema/mapplanmodifier/will_have_size_at_least_test.go new file mode 100644 index 00000000..414779e5 --- /dev/null +++ b/resource/schema/mapplanmodifier/will_have_size_at_least_test.go @@ -0,0 +1,105 @@ +// 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/types" +) + +func TestWillHaveSizeAtLeastModifierPlanModifyMap(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + minVal int + request planmodifier.MapRequest + expected *planmodifier.MapResponse + }{ + "known-plan": { + minVal: 5, + request: planmodifier.MapRequest{ + StateValue: types.MapValueMust(types.StringType, map[string]attr.Value{"key1": types.StringValue("hello"), "key2": types.StringValue("world")}), + PlanValue: types.MapValueMust(types.StringType, map[string]attr.Value{"key1": types.StringValue("hello"), "key2": types.StringValue("world"), "key3": types.StringValue("!")}), + ConfigValue: types.MapNull(types.StringType), + }, + expected: &planmodifier.MapResponse{ + PlanValue: types.MapValueMust(types.StringType, map[string]attr.Value{"key1": types.StringValue("hello"), "key2": types.StringValue("world"), "key3": types.StringValue("!")}), + }, + }, + "unknown-config": { + // this is the situation in which a user is + // interpolating into a field. We want that to still + // show up as unknown (with no refinement), 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 + minVal: 5, + request: planmodifier.MapRequest{ + StateValue: types.MapValueMust(types.StringType, map[string]attr.Value{"key1": types.StringValue("hello"), "key2": types.StringValue("world"), "key3": types.StringValue("!")}), + PlanValue: types.MapUnknown(types.StringType), + ConfigValue: types.MapUnknown(types.StringType), + }, + expected: &planmodifier.MapResponse{ + PlanValue: types.MapUnknown(types.StringType), + }, + }, + "unknown-plan-null-state": { + minVal: 5, + request: planmodifier.MapRequest{ + StateValue: types.MapNull(types.StringType), + PlanValue: types.MapUnknown(types.StringType), + ConfigValue: types.MapNull(types.StringType), + }, + expected: &planmodifier.MapResponse{ + PlanValue: types.MapUnknown(types.StringType).RefineWithLengthLowerBound(5), + }, + }, + "unknown-plan-non-null-state": { + minVal: 3, + request: planmodifier.MapRequest{ + StateValue: types.MapValueMust(types.StringType, map[string]attr.Value{"key1": types.StringValue("hello"), "key2": types.StringValue("world"), "key3": types.StringValue("!")}), + PlanValue: types.MapUnknown(types.StringType), + ConfigValue: types.MapNull(types.StringType), + }, + expected: &planmodifier.MapResponse{ + PlanValue: types.MapUnknown(types.StringType).RefineWithLengthLowerBound(3), + }, + }, + "unknown-plan-preserve-existing-refinement": { + minVal: 2, + request: planmodifier.MapRequest{ + StateValue: types.MapNull(types.StringType), + PlanValue: types.MapUnknown(types.StringType).RefineWithLengthUpperBound(6), + ConfigValue: types.MapNull(types.StringType), + }, + expected: &planmodifier.MapResponse{ + PlanValue: types.MapUnknown(types.StringType).RefineWithLengthUpperBound(6).RefineWithLengthLowerBound(2), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.MapResponse{ + PlanValue: testCase.request.PlanValue, + } + + mapplanmodifier.WillHaveSizeAtLeast(testCase.minVal).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/will_have_size_at_most.go b/resource/schema/mapplanmodifier/will_have_size_at_most.go new file mode 100644 index 00000000..75979a39 --- /dev/null +++ b/resource/schema/mapplanmodifier/will_have_size_at_most.go @@ -0,0 +1,50 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package mapplanmodifier + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// WillHaveSizeAtMost returns a plan modifier that will add a refinement to an unknown planned value +// which promises that: +// - The final value will not be null. +// - The final size of the map value will be at most the provided maximum value. +// +// This unknown value refinement allows Terraform to validate more of the configuration during plan +// and evaluate conditional logic in meta-arguments such as "count". +func WillHaveSizeAtMost(maxVal int) planmodifier.Map { + return willHaveSizeAtMostModifier{ + max: maxVal, + } +} + +type willHaveSizeAtMostModifier struct { + max int +} + +func (m willHaveSizeAtMostModifier) Description(_ context.Context) string { + return fmt.Sprintf("Promises the value of this attribute will contain at most %d elements once it becomes known", m.max) +} + +func (m willHaveSizeAtMostModifier) MarkdownDescription(_ context.Context) string { + return fmt.Sprintf("Promises the value of this attribute will contain at most %d elements once it becomes known", m.max) +} + +func (m willHaveSizeAtMostModifier) PlanModifyMap(ctx context.Context, req planmodifier.MapRequest, resp *planmodifier.MapResponse) { + // 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.PlanValue.RefineWithLengthUpperBound(int64(m.max)) +} diff --git a/resource/schema/mapplanmodifier/will_have_size_at_most_test.go b/resource/schema/mapplanmodifier/will_have_size_at_most_test.go new file mode 100644 index 00000000..b9970e58 --- /dev/null +++ b/resource/schema/mapplanmodifier/will_have_size_at_most_test.go @@ -0,0 +1,105 @@ +// 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/types" +) + +func TestWillHaveSizeAtMostModifierPlanModifyMap(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + maxVal int + request planmodifier.MapRequest + expected *planmodifier.MapResponse + }{ + "known-plan": { + maxVal: 10, + request: planmodifier.MapRequest{ + StateValue: types.MapValueMust(types.StringType, map[string]attr.Value{"key1": types.StringValue("hello"), "key2": types.StringValue("world")}), + PlanValue: types.MapValueMust(types.StringType, map[string]attr.Value{"key1": types.StringValue("hello"), "key2": types.StringValue("world"), "key3": types.StringValue("!")}), + ConfigValue: types.MapNull(types.StringType), + }, + expected: &planmodifier.MapResponse{ + PlanValue: types.MapValueMust(types.StringType, map[string]attr.Value{"key1": types.StringValue("hello"), "key2": types.StringValue("world"), "key3": types.StringValue("!")}), + }, + }, + "unknown-config": { + // this is the situation in which a user is + // interpolating into a field. We want that to still + // show up as unknown (with no refinement), 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 + maxVal: 10, + request: planmodifier.MapRequest{ + StateValue: types.MapValueMust(types.StringType, map[string]attr.Value{"key1": types.StringValue("hello"), "key2": types.StringValue("world"), "key3": types.StringValue("!")}), + PlanValue: types.MapUnknown(types.StringType), + ConfigValue: types.MapUnknown(types.StringType), + }, + expected: &planmodifier.MapResponse{ + PlanValue: types.MapUnknown(types.StringType), + }, + }, + "unknown-plan-null-state": { + maxVal: 10, + request: planmodifier.MapRequest{ + StateValue: types.MapNull(types.StringType), + PlanValue: types.MapUnknown(types.StringType), + ConfigValue: types.MapNull(types.StringType), + }, + expected: &planmodifier.MapResponse{ + PlanValue: types.MapUnknown(types.StringType).RefineWithLengthUpperBound(10), + }, + }, + "unknown-plan-non-null-state": { + maxVal: 4, + request: planmodifier.MapRequest{ + StateValue: types.MapValueMust(types.StringType, map[string]attr.Value{"key1": types.StringValue("hello"), "key2": types.StringValue("world"), "key3": types.StringValue("!")}), + PlanValue: types.MapUnknown(types.StringType), + ConfigValue: types.MapNull(types.StringType), + }, + expected: &planmodifier.MapResponse{ + PlanValue: types.MapUnknown(types.StringType).RefineWithLengthUpperBound(4), + }, + }, + "unknown-plan-preserve-existing-refinement": { + maxVal: 6, + request: planmodifier.MapRequest{ + StateValue: types.MapNull(types.StringType), + PlanValue: types.MapUnknown(types.StringType).RefineWithLengthLowerBound(2), + ConfigValue: types.MapNull(types.StringType), + }, + expected: &planmodifier.MapResponse{ + PlanValue: types.MapUnknown(types.StringType).RefineWithLengthLowerBound(2).RefineWithLengthUpperBound(6), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.MapResponse{ + PlanValue: testCase.request.PlanValue, + } + + mapplanmodifier.WillHaveSizeAtMost(testCase.maxVal).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/will_have_size_between.go b/resource/schema/mapplanmodifier/will_have_size_between.go new file mode 100644 index 00000000..f999114a --- /dev/null +++ b/resource/schema/mapplanmodifier/will_have_size_between.go @@ -0,0 +1,55 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package mapplanmodifier + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// WillHaveSizeBetween returns a plan modifier that will add a refinement to an unknown planned value +// which promises that: +// - The final value will not be null. +// - The final size of the map value will be at least the provided minimum value. +// - The final size of the map value will be at most the provided maximum value. +// +// This unknown value refinement allows Terraform to validate more of the configuration during plan +// and evaluate conditional logic in meta-arguments such as "count". +func WillHaveSizeBetween(minVal, maxVal int) planmodifier.Map { + return willHaveSizeBetweenModifier{ + min: minVal, + max: maxVal, + } +} + +type willHaveSizeBetweenModifier struct { + min int + max int +} + +func (m willHaveSizeBetweenModifier) Description(_ context.Context) string { + return fmt.Sprintf("Promises the value of this attribute will contain at least %d elements and at most %d elements once it becomes known", m.min, m.max) +} + +func (m willHaveSizeBetweenModifier) MarkdownDescription(_ context.Context) string { + return fmt.Sprintf("Promises the value of this attribute will contain at least %d elements and at most %d elements once it becomes known", m.min, m.max) +} + +func (m willHaveSizeBetweenModifier) PlanModifyMap(ctx context.Context, req planmodifier.MapRequest, resp *planmodifier.MapResponse) { + // 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.PlanValue. + RefineWithLengthLowerBound(int64(m.min)). + RefineWithLengthUpperBound(int64(m.max)) +} diff --git a/resource/schema/mapplanmodifier/will_have_size_between_test.go b/resource/schema/mapplanmodifier/will_have_size_between_test.go new file mode 100644 index 00000000..528f9937 --- /dev/null +++ b/resource/schema/mapplanmodifier/will_have_size_between_test.go @@ -0,0 +1,111 @@ +// 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/types" +) + +func TestWillHaveSizeBetweenModifierPlanModifyMap(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + minVal int + maxVal int + request planmodifier.MapRequest + expected *planmodifier.MapResponse + }{ + "known-plan": { + minVal: 5, + maxVal: 10, + request: planmodifier.MapRequest{ + StateValue: types.MapValueMust(types.StringType, map[string]attr.Value{"key1": types.StringValue("hello"), "key2": types.StringValue("world")}), + PlanValue: types.MapValueMust(types.StringType, map[string]attr.Value{"key1": types.StringValue("hello"), "key2": types.StringValue("world"), "key3": types.StringValue("!")}), + ConfigValue: types.MapNull(types.StringType), + }, + expected: &planmodifier.MapResponse{ + PlanValue: types.MapValueMust(types.StringType, map[string]attr.Value{"key1": types.StringValue("hello"), "key2": types.StringValue("world"), "key3": types.StringValue("!")}), + }, + }, + "unknown-config": { + // this is the situation in which a user is + // interpolating into a field. We want that to still + // show up as unknown (with no refinement), 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 + minVal: 5, + maxVal: 10, + request: planmodifier.MapRequest{ + StateValue: types.MapValueMust(types.StringType, map[string]attr.Value{"key1": types.StringValue("hello"), "key2": types.StringValue("world"), "key3": types.StringValue("!")}), + PlanValue: types.MapUnknown(types.StringType), + ConfigValue: types.MapUnknown(types.StringType), + }, + expected: &planmodifier.MapResponse{ + PlanValue: types.MapUnknown(types.StringType), + }, + }, + "unknown-plan-null-state": { + minVal: 5, + maxVal: 10, + request: planmodifier.MapRequest{ + StateValue: types.MapNull(types.StringType), + PlanValue: types.MapUnknown(types.StringType), + ConfigValue: types.MapNull(types.StringType), + }, + expected: &planmodifier.MapResponse{ + PlanValue: types.MapUnknown(types.StringType).RefineWithLengthLowerBound(5).RefineWithLengthUpperBound(10), + }, + }, + "unknown-plan-non-null-state": { + minVal: 3, + maxVal: 4, + request: planmodifier.MapRequest{ + StateValue: types.MapValueMust(types.StringType, map[string]attr.Value{"key1": types.StringValue("hello"), "key2": types.StringValue("world"), "key3": types.StringValue("!")}), + PlanValue: types.MapUnknown(types.StringType), + ConfigValue: types.MapNull(types.StringType), + }, + expected: &planmodifier.MapResponse{ + PlanValue: types.MapUnknown(types.StringType).RefineWithLengthLowerBound(3).RefineWithLengthUpperBound(4), + }, + }, + "unknown-plan-preserve-existing-refinement": { + minVal: 2, + maxVal: 6, + request: planmodifier.MapRequest{ + StateValue: types.MapNull(types.StringType), + PlanValue: types.MapUnknown(types.StringType).RefineAsNotNull(), + ConfigValue: types.MapNull(types.StringType), + }, + expected: &planmodifier.MapResponse{ + PlanValue: types.MapUnknown(types.StringType).RefineAsNotNull().RefineWithLengthLowerBound(2).RefineWithLengthUpperBound(6), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.MapResponse{ + PlanValue: testCase.request.PlanValue, + } + + mapplanmodifier.WillHaveSizeBetween(testCase.minVal, testCase.maxVal).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/will_not_be_null.go b/resource/schema/mapplanmodifier/will_not_be_null.go new file mode 100644 index 00000000..75dc247d --- /dev/null +++ b/resource/schema/mapplanmodifier/will_not_be_null.go @@ -0,0 +1,50 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package mapplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// WillNotBeNull returns a plan modifier that will add a refinement to an unknown planned value +// which promises that the final value will not be null. +// +// This unknown value refinement allows Terraform to validate more of the configuration during plan +// and evaluate conditional logic in meta-arguments such as "count": +// +// resource "examplecloud_thing" "b" { +// // Will successfully evaluate during plan with a "not null" refinement on "map_attribute" +// count = examplecloud_thing.a.map_attribute != null ? 1 : 0 +// +// // .. resource config +// } +func WillNotBeNull() planmodifier.Map { + return willNotBeNullModifier{} +} + +type willNotBeNullModifier struct{} + +func (m willNotBeNullModifier) Description(_ context.Context) string { + return "Promises the value of this attribute will not be null once it becomes known" +} + +func (m willNotBeNullModifier) MarkdownDescription(_ context.Context) string { + return "Promises the value of this attribute will not be null once it becomes known" +} + +func (m willNotBeNullModifier) PlanModifyMap(ctx context.Context, req planmodifier.MapRequest, resp *planmodifier.MapResponse) { + // 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.PlanValue.RefineAsNotNull() +} diff --git a/resource/schema/mapplanmodifier/will_not_be_null_test.go b/resource/schema/mapplanmodifier/will_not_be_null_test.go new file mode 100644 index 00000000..2c9092c4 --- /dev/null +++ b/resource/schema/mapplanmodifier/will_not_be_null_test.go @@ -0,0 +1,99 @@ +// 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/types" +) + +func TestWillNotBeNullModifierPlanModifyMap(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + request planmodifier.MapRequest + expected *planmodifier.MapResponse + }{ + "known-plan": { + request: planmodifier.MapRequest{ + StateValue: types.MapValueMust(types.StringType, map[string]attr.Value{"key1": types.StringValue("hello"), "key2": types.StringValue("world")}), + PlanValue: types.MapValueMust(types.StringType, map[string]attr.Value{"key1": types.StringValue("hello"), "key2": types.StringValue("world"), "key3": types.StringValue("!")}), + ConfigValue: types.MapNull(types.StringType), + }, + expected: &planmodifier.MapResponse{ + PlanValue: types.MapValueMust(types.StringType, map[string]attr.Value{"key1": types.StringValue("hello"), "key2": types.StringValue("world"), "key3": types.StringValue("!")}), + }, + }, + "unknown-config": { + // this is the situation in which a user is + // interpolating into a field. We want that to still + // show up as unknown (with no refinement), 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{"key1": types.StringValue("hello"), "key2": types.StringValue("world"), "key3": types.StringValue("!")}), + PlanValue: types.MapUnknown(types.StringType), + ConfigValue: types.MapUnknown(types.StringType), + }, + expected: &planmodifier.MapResponse{ + PlanValue: types.MapUnknown(types.StringType), + }, + }, + "unknown-plan-null-state": { + request: planmodifier.MapRequest{ + StateValue: types.MapNull(types.StringType), + PlanValue: types.MapUnknown(types.StringType), + ConfigValue: types.MapNull(types.StringType), + }, + expected: &planmodifier.MapResponse{ + PlanValue: types.MapUnknown(types.StringType).RefineAsNotNull(), + }, + }, + "unknown-plan-non-null-state": { + request: planmodifier.MapRequest{ + StateValue: types.MapValueMust(types.StringType, map[string]attr.Value{"key1": types.StringValue("hello"), "key2": types.StringValue("world"), "key3": types.StringValue("!")}), + PlanValue: types.MapUnknown(types.StringType), + ConfigValue: types.MapNull(types.StringType), + }, + expected: &planmodifier.MapResponse{ + PlanValue: types.MapUnknown(types.StringType).RefineAsNotNull(), + }, + }, + "unknown-plan-preserve-existing-refinement": { + request: planmodifier.MapRequest{ + StateValue: types.MapNull(types.StringType), + PlanValue: types.MapUnknown(types.StringType).RefineWithLengthLowerBound(10), + ConfigValue: types.MapNull(types.StringType), + }, + expected: &planmodifier.MapResponse{ + PlanValue: types.MapUnknown(types.StringType).RefineAsNotNull().RefineWithLengthLowerBound(10), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.MapResponse{ + PlanValue: testCase.request.PlanValue, + } + + mapplanmodifier.WillNotBeNull().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/numberplanmodifier/will_be_at_least.go b/resource/schema/numberplanmodifier/will_be_at_least.go new file mode 100644 index 00000000..3dbf5651 --- /dev/null +++ b/resource/schema/numberplanmodifier/will_be_at_least.go @@ -0,0 +1,51 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package numberplanmodifier + +import ( + "context" + "fmt" + "math/big" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// WillBeAtLeast returns a plan modifier that will add a refinement to an unknown planned value +// which promises that: +// - The final value will not be null. +// - The final value will be greater than or equal to the provided minimum value. +// +// This unknown value refinement allows Terraform to validate more of the configuration during plan +// and evaluate conditional logic in meta-arguments such as "count". +func WillBeAtLeast(minVal *big.Float) planmodifier.Number { + return willBeAtLeastModifier{ + min: minVal, + } +} + +type willBeAtLeastModifier struct { + min *big.Float +} + +func (m willBeAtLeastModifier) Description(_ context.Context) string { + return fmt.Sprintf("Promises the value of this attribute will be at least %s once it becomes known", m.min.String()) +} + +func (m willBeAtLeastModifier) MarkdownDescription(_ context.Context) string { + return fmt.Sprintf("Promises the value of this attribute will be at least %s once it becomes known", m.min.String()) +} + +func (m willBeAtLeastModifier) PlanModifyNumber(ctx context.Context, req planmodifier.NumberRequest, resp *planmodifier.NumberResponse) { + // 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.PlanValue.RefineWithLowerBound(m.min, true) +} diff --git a/resource/schema/numberplanmodifier/will_be_at_least_test.go b/resource/schema/numberplanmodifier/will_be_at_least_test.go new file mode 100644 index 00000000..c9978b22 --- /dev/null +++ b/resource/schema/numberplanmodifier/will_be_at_least_test.go @@ -0,0 +1,105 @@ +// 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/types" +) + +func TestWillBeAtLeastModifierPlanModifyNumber(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + minVal *big.Float + request planmodifier.NumberRequest + expected *planmodifier.NumberResponse + }{ + "known-plan": { + minVal: big.NewFloat(5.5), + request: planmodifier.NumberRequest{ + StateValue: types.NumberValue(big.NewFloat(5.5)), + PlanValue: types.NumberValue(big.NewFloat(10.1)), + ConfigValue: types.NumberNull(), + }, + expected: &planmodifier.NumberResponse{ + PlanValue: types.NumberValue(big.NewFloat(10.1)), + }, + }, + "unknown-config": { + // this is the situation in which a user is + // interpolating into a field. We want that to still + // show up as unknown (with no refinement), 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 + minVal: big.NewFloat(5.5), + request: planmodifier.NumberRequest{ + StateValue: types.NumberValue(big.NewFloat(10.1)), + PlanValue: types.NumberUnknown(), + ConfigValue: types.NumberUnknown(), + }, + expected: &planmodifier.NumberResponse{ + PlanValue: types.NumberUnknown(), + }, + }, + "unknown-plan-null-state": { + minVal: big.NewFloat(5.5), + request: planmodifier.NumberRequest{ + StateValue: types.NumberNull(), + PlanValue: types.NumberUnknown(), + ConfigValue: types.NumberNull(), + }, + expected: &planmodifier.NumberResponse{ + PlanValue: types.NumberUnknown().RefineWithLowerBound(big.NewFloat(5.5), true), + }, + }, + "unknown-plan-non-null-state": { + minVal: big.NewFloat(3.5), + request: planmodifier.NumberRequest{ + StateValue: types.NumberValue(big.NewFloat(10.1)), + PlanValue: types.NumberUnknown(), + ConfigValue: types.NumberNull(), + }, + expected: &planmodifier.NumberResponse{ + PlanValue: types.NumberUnknown().RefineWithLowerBound(big.NewFloat(3.5), true), + }, + }, + "unknown-plan-preserve-existing-refinement": { + minVal: big.NewFloat(2.5), + request: planmodifier.NumberRequest{ + StateValue: types.NumberNull(), + PlanValue: types.NumberUnknown().RefineWithUpperBound(big.NewFloat(6.1), false), + ConfigValue: types.NumberNull(), + }, + expected: &planmodifier.NumberResponse{ + PlanValue: types.NumberUnknown().RefineWithUpperBound(big.NewFloat(6.1), false).RefineWithLowerBound(big.NewFloat(2.5), true), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.NumberResponse{ + PlanValue: testCase.request.PlanValue, + } + + numberplanmodifier.WillBeAtLeast(testCase.minVal).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/will_be_at_most.go b/resource/schema/numberplanmodifier/will_be_at_most.go new file mode 100644 index 00000000..116a10d0 --- /dev/null +++ b/resource/schema/numberplanmodifier/will_be_at_most.go @@ -0,0 +1,51 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package numberplanmodifier + +import ( + "context" + "fmt" + "math/big" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// WillBeAtMost returns a plan modifier that will add a refinement to an unknown planned value +// which promises that: +// - The final value will not be null. +// - The final value will be less than or equal to the provided maximum value. +// +// This unknown value refinement allows Terraform to validate more of the configuration during plan +// and evaluate conditional logic in meta-arguments such as "count". +func WillBeAtMost(maxVal *big.Float) planmodifier.Number { + return willBeAtMostModifier{ + max: maxVal, + } +} + +type willBeAtMostModifier struct { + max *big.Float +} + +func (m willBeAtMostModifier) Description(_ context.Context) string { + return fmt.Sprintf("Promises the value of this attribute will be at most %s once it becomes known", m.max.String()) +} + +func (m willBeAtMostModifier) MarkdownDescription(_ context.Context) string { + return fmt.Sprintf("Promises the value of this attribute will be at most %s once it becomes known", m.max.String()) +} + +func (m willBeAtMostModifier) PlanModifyNumber(ctx context.Context, req planmodifier.NumberRequest, resp *planmodifier.NumberResponse) { + // 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.PlanValue.RefineWithUpperBound(m.max, true) +} diff --git a/resource/schema/numberplanmodifier/will_be_at_most_test.go b/resource/schema/numberplanmodifier/will_be_at_most_test.go new file mode 100644 index 00000000..a2391112 --- /dev/null +++ b/resource/schema/numberplanmodifier/will_be_at_most_test.go @@ -0,0 +1,105 @@ +// 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/types" +) + +func TestWillBeAtMostModifierPlanModifyNumber(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + maxVal *big.Float + request planmodifier.NumberRequest + expected *planmodifier.NumberResponse + }{ + "known-plan": { + maxVal: big.NewFloat(10.1), + request: planmodifier.NumberRequest{ + StateValue: types.NumberValue(big.NewFloat(5.5)), + PlanValue: types.NumberValue(big.NewFloat(10.1)), + ConfigValue: types.NumberNull(), + }, + expected: &planmodifier.NumberResponse{ + PlanValue: types.NumberValue(big.NewFloat(10.1)), + }, + }, + "unknown-config": { + // this is the situation in which a user is + // interpolating into a field. We want that to still + // show up as unknown (with no refinement), 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 + maxVal: big.NewFloat(10.1), + request: planmodifier.NumberRequest{ + StateValue: types.NumberValue(big.NewFloat(10.1)), + PlanValue: types.NumberUnknown(), + ConfigValue: types.NumberUnknown(), + }, + expected: &planmodifier.NumberResponse{ + PlanValue: types.NumberUnknown(), + }, + }, + "unknown-plan-null-state": { + maxVal: big.NewFloat(10.1), + request: planmodifier.NumberRequest{ + StateValue: types.NumberNull(), + PlanValue: types.NumberUnknown(), + ConfigValue: types.NumberNull(), + }, + expected: &planmodifier.NumberResponse{ + PlanValue: types.NumberUnknown().RefineWithUpperBound(big.NewFloat(10.1), true), + }, + }, + "unknown-plan-non-null-state": { + maxVal: big.NewFloat(4.1), + request: planmodifier.NumberRequest{ + StateValue: types.NumberValue(big.NewFloat(10.1)), + PlanValue: types.NumberUnknown(), + ConfigValue: types.NumberNull(), + }, + expected: &planmodifier.NumberResponse{ + PlanValue: types.NumberUnknown().RefineWithUpperBound(big.NewFloat(4.1), true), + }, + }, + "unknown-plan-preserve-existing-refinement": { + maxVal: big.NewFloat(6.1), + request: planmodifier.NumberRequest{ + StateValue: types.NumberNull(), + PlanValue: types.NumberUnknown().RefineWithLowerBound(big.NewFloat(2.5), false), + ConfigValue: types.NumberNull(), + }, + expected: &planmodifier.NumberResponse{ + PlanValue: types.NumberUnknown().RefineWithLowerBound(big.NewFloat(2.5), false).RefineWithUpperBound(big.NewFloat(6.1), true), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.NumberResponse{ + PlanValue: testCase.request.PlanValue, + } + + numberplanmodifier.WillBeAtMost(testCase.maxVal).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/will_be_between.go b/resource/schema/numberplanmodifier/will_be_between.go new file mode 100644 index 00000000..39ab0d07 --- /dev/null +++ b/resource/schema/numberplanmodifier/will_be_between.go @@ -0,0 +1,56 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package numberplanmodifier + +import ( + "context" + "fmt" + "math/big" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// WillBeBetween returns a plan modifier that will add a refinement to an unknown planned value +// which promises that: +// - The final value will not be null. +// - The final value will be greater than or equal to the provided minimum value. +// - The final value will be less than or equal to the provided maximum value. +// +// This unknown value refinement allows Terraform to validate more of the configuration during plan +// and evaluate conditional logic in meta-arguments such as "count". +func WillBeBetween(minVal, maxVal *big.Float) planmodifier.Number { + return willBeBetweenModifier{ + min: minVal, + max: maxVal, + } +} + +type willBeBetweenModifier struct { + min *big.Float + max *big.Float +} + +func (m willBeBetweenModifier) Description(_ context.Context) string { + return fmt.Sprintf("Promises the value of this attribute will be between %s and %s once it becomes known", m.min.String(), m.max.String()) +} + +func (m willBeBetweenModifier) MarkdownDescription(_ context.Context) string { + return fmt.Sprintf("Promises the value of this attribute will be between %s and %s once it becomes known", m.min.String(), m.max.String()) +} + +func (m willBeBetweenModifier) PlanModifyNumber(ctx context.Context, req planmodifier.NumberRequest, resp *planmodifier.NumberResponse) { + // 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.PlanValue. + RefineWithLowerBound(m.min, true). + RefineWithUpperBound(m.max, true) +} diff --git a/resource/schema/numberplanmodifier/will_be_between_test.go b/resource/schema/numberplanmodifier/will_be_between_test.go new file mode 100644 index 00000000..40e962ac --- /dev/null +++ b/resource/schema/numberplanmodifier/will_be_between_test.go @@ -0,0 +1,111 @@ +// 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/types" +) + +func TestWillBeBetweenModifierPlanModifyNumber(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + minVal *big.Float + maxVal *big.Float + request planmodifier.NumberRequest + expected *planmodifier.NumberResponse + }{ + "known-plan": { + minVal: big.NewFloat(5.5), + maxVal: big.NewFloat(10.1), + request: planmodifier.NumberRequest{ + StateValue: types.NumberValue(big.NewFloat(5.5)), + PlanValue: types.NumberValue(big.NewFloat(10.1)), + ConfigValue: types.NumberNull(), + }, + expected: &planmodifier.NumberResponse{ + PlanValue: types.NumberValue(big.NewFloat(10.1)), + }, + }, + "unknown-config": { + // this is the situation in which a user is + // interpolating into a field. We want that to still + // show up as unknown (with no refinement), 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 + minVal: big.NewFloat(5.5), + maxVal: big.NewFloat(10.1), + request: planmodifier.NumberRequest{ + StateValue: types.NumberValue(big.NewFloat(10.1)), + PlanValue: types.NumberUnknown(), + ConfigValue: types.NumberUnknown(), + }, + expected: &planmodifier.NumberResponse{ + PlanValue: types.NumberUnknown(), + }, + }, + "unknown-plan-null-state": { + minVal: big.NewFloat(5.5), + maxVal: big.NewFloat(10.1), + request: planmodifier.NumberRequest{ + StateValue: types.NumberNull(), + PlanValue: types.NumberUnknown(), + ConfigValue: types.NumberNull(), + }, + expected: &planmodifier.NumberResponse{ + PlanValue: types.NumberUnknown().RefineWithLowerBound(big.NewFloat(5.5), true).RefineWithUpperBound(big.NewFloat(10.1), true), + }, + }, + "unknown-plan-non-null-state": { + minVal: big.NewFloat(3.5), + maxVal: big.NewFloat(4.1), + request: planmodifier.NumberRequest{ + StateValue: types.NumberValue(big.NewFloat(10.1)), + PlanValue: types.NumberUnknown(), + ConfigValue: types.NumberNull(), + }, + expected: &planmodifier.NumberResponse{ + PlanValue: types.NumberUnknown().RefineWithLowerBound(big.NewFloat(3.5), true).RefineWithUpperBound(big.NewFloat(4.1), true), + }, + }, + "unknown-plan-preserve-existing-refinement": { + minVal: big.NewFloat(2.5), + maxVal: big.NewFloat(6.1), + request: planmodifier.NumberRequest{ + StateValue: types.NumberNull(), + PlanValue: types.NumberUnknown().RefineAsNotNull(), + ConfigValue: types.NumberNull(), + }, + expected: &planmodifier.NumberResponse{ + PlanValue: types.NumberUnknown().RefineAsNotNull().RefineWithLowerBound(big.NewFloat(2.5), true).RefineWithUpperBound(big.NewFloat(6.1), true), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.NumberResponse{ + PlanValue: testCase.request.PlanValue, + } + + numberplanmodifier.WillBeBetween(testCase.minVal, testCase.maxVal).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/will_not_be_null.go b/resource/schema/numberplanmodifier/will_not_be_null.go new file mode 100644 index 00000000..2592f6a0 --- /dev/null +++ b/resource/schema/numberplanmodifier/will_not_be_null.go @@ -0,0 +1,50 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package numberplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// WillNotBeNull returns a plan modifier that will add a refinement to an unknown planned value +// which promises that the final value will not be null. +// +// This unknown value refinement allows Terraform to validate more of the configuration during plan +// and evaluate conditional logic in meta-arguments such as "count": +// +// resource "examplecloud_thing" "b" { +// // Will successfully evaluate during plan with a "not null" refinement on "number_attribute" +// count = examplecloud_thing.a.number_attribute != null ? 1 : 0 +// +// // .. resource config +// } +func WillNotBeNull() planmodifier.Number { + return willNotBeNullModifier{} +} + +type willNotBeNullModifier struct{} + +func (m willNotBeNullModifier) Description(_ context.Context) string { + return "Promises the value of this attribute will not be null once it becomes known" +} + +func (m willNotBeNullModifier) MarkdownDescription(_ context.Context) string { + return "Promises the value of this attribute will not be null once it becomes known" +} + +func (m willNotBeNullModifier) PlanModifyNumber(ctx context.Context, req planmodifier.NumberRequest, resp *planmodifier.NumberResponse) { + // 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.PlanValue.RefineAsNotNull() +} diff --git a/resource/schema/numberplanmodifier/will_not_be_null_test.go b/resource/schema/numberplanmodifier/will_not_be_null_test.go new file mode 100644 index 00000000..1093d04e --- /dev/null +++ b/resource/schema/numberplanmodifier/will_not_be_null_test.go @@ -0,0 +1,99 @@ +// 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/types" +) + +func TestWillNotBeNullModifierPlanModifyNumber(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + request planmodifier.NumberRequest + expected *planmodifier.NumberResponse + }{ + "known-plan": { + request: planmodifier.NumberRequest{ + StateValue: types.NumberValue(big.NewFloat(5.5)), + PlanValue: types.NumberValue(big.NewFloat(10.1)), + ConfigValue: types.NumberNull(), + }, + expected: &planmodifier.NumberResponse{ + PlanValue: types.NumberValue(big.NewFloat(10.1)), + }, + }, + "unknown-config": { + // this is the situation in which a user is + // interpolating into a field. We want that to still + // show up as unknown (with no refinement), 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{ + StateValue: types.NumberValue(big.NewFloat(10.1)), + PlanValue: types.NumberUnknown(), + ConfigValue: types.NumberUnknown(), + }, + expected: &planmodifier.NumberResponse{ + PlanValue: types.NumberUnknown(), + }, + }, + "unknown-plan-null-state": { + request: planmodifier.NumberRequest{ + StateValue: types.NumberNull(), + PlanValue: types.NumberUnknown(), + ConfigValue: types.NumberNull(), + }, + expected: &planmodifier.NumberResponse{ + PlanValue: types.NumberUnknown().RefineAsNotNull(), + }, + }, + "unknown-plan-non-null-state": { + request: planmodifier.NumberRequest{ + StateValue: types.NumberValue(big.NewFloat(10.1)), + PlanValue: types.NumberUnknown(), + ConfigValue: types.NumberNull(), + }, + expected: &planmodifier.NumberResponse{ + PlanValue: types.NumberUnknown().RefineAsNotNull(), + }, + }, + "unknown-plan-preserve-existing-refinement": { + request: planmodifier.NumberRequest{ + StateValue: types.NumberNull(), + PlanValue: types.NumberUnknown().RefineWithLowerBound(big.NewFloat(10.1), false), + ConfigValue: types.NumberNull(), + }, + expected: &planmodifier.NumberResponse{ + PlanValue: types.NumberUnknown().RefineAsNotNull().RefineWithLowerBound(big.NewFloat(10.1), false), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.NumberResponse{ + PlanValue: testCase.request.PlanValue, + } + + numberplanmodifier.WillNotBeNull().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/objectplanmodifier/will_not_be_null.go b/resource/schema/objectplanmodifier/will_not_be_null.go new file mode 100644 index 00000000..d0125483 --- /dev/null +++ b/resource/schema/objectplanmodifier/will_not_be_null.go @@ -0,0 +1,50 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package objectplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// WillNotBeNull returns a plan modifier that will add a refinement to an unknown planned value +// which promises that the final value will not be null. +// +// This unknown value refinement allows Terraform to validate more of the configuration during plan +// and evaluate conditional logic in meta-arguments such as "count": +// +// resource "examplecloud_thing" "b" { +// // Will successfully evaluate during plan with a "not null" refinement on "object_attribute" +// count = examplecloud_thing.a.object_attribute != null ? 1 : 0 +// +// // .. resource config +// } +func WillNotBeNull() planmodifier.Object { + return willNotBeNullModifier{} +} + +type willNotBeNullModifier struct{} + +func (m willNotBeNullModifier) Description(_ context.Context) string { + return "Promises the value of this attribute will not be null once it becomes known" +} + +func (m willNotBeNullModifier) MarkdownDescription(_ context.Context) string { + return "Promises the value of this attribute will not be null once it becomes known" +} + +func (m willNotBeNullModifier) PlanModifyObject(ctx context.Context, req planmodifier.ObjectRequest, resp *planmodifier.ObjectResponse) { + // 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.PlanValue.RefineAsNotNull() +} diff --git a/resource/schema/objectplanmodifier/will_not_be_null_test.go b/resource/schema/objectplanmodifier/will_not_be_null_test.go new file mode 100644 index 00000000..4ff331a1 --- /dev/null +++ b/resource/schema/objectplanmodifier/will_not_be_null_test.go @@ -0,0 +1,103 @@ +// 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/types" +) + +func TestWillNotBeNullModifierPlanModifyObject(t *testing.T) { + t.Parallel() + + objType := map[string]attr.Type{ + "attr_one": types.StringType, + } + + testCases := map[string]struct { + request planmodifier.ObjectRequest + expected *planmodifier.ObjectResponse + }{ + "known-plan": { + request: planmodifier.ObjectRequest{ + StateValue: types.ObjectValueMust(objType, map[string]attr.Value{ + "attr_one": types.StringValue("hello!"), + }), + PlanValue: types.ObjectValueMust(objType, map[string]attr.Value{ + "attr_one": types.StringValue("world!"), + }), + ConfigValue: types.ObjectNull(objType), + }, + expected: &planmodifier.ObjectResponse{ + PlanValue: types.ObjectValueMust(objType, map[string]attr.Value{ + "attr_one": types.StringValue("world!"), + }), + }, + }, + "unknown-config": { + // this is the situation in which a user is + // interpolating into a field. We want that to still + // show up as unknown (with no refinement), 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(objType, map[string]attr.Value{ + "attr_one": types.StringValue("world!"), + }), + PlanValue: types.ObjectUnknown(objType), + ConfigValue: types.ObjectUnknown(objType), + }, + expected: &planmodifier.ObjectResponse{ + PlanValue: types.ObjectUnknown(objType), + }, + }, + "unknown-plan-null-state": { + request: planmodifier.ObjectRequest{ + StateValue: types.ObjectNull(objType), + PlanValue: types.ObjectUnknown(objType), + ConfigValue: types.ObjectNull(objType), + }, + expected: &planmodifier.ObjectResponse{ + PlanValue: types.ObjectUnknown(objType).RefineAsNotNull(), + }, + }, + "unknown-plan-non-null-state": { + request: planmodifier.ObjectRequest{ + StateValue: types.ObjectValueMust(objType, map[string]attr.Value{ + "attr_one": types.StringValue("world!"), + }), + PlanValue: types.ObjectUnknown(objType), + ConfigValue: types.ObjectNull(objType), + }, + expected: &planmodifier.ObjectResponse{ + PlanValue: types.ObjectUnknown(objType).RefineAsNotNull(), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.ObjectResponse{ + PlanValue: testCase.request.PlanValue, + } + + objectplanmodifier.WillNotBeNull().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/setplanmodifier/will_have_size_at_least.go b/resource/schema/setplanmodifier/will_have_size_at_least.go new file mode 100644 index 00000000..76bfc330 --- /dev/null +++ b/resource/schema/setplanmodifier/will_have_size_at_least.go @@ -0,0 +1,50 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package setplanmodifier + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// WillHaveSizeAtLeast returns a plan modifier that will add a refinement to an unknown planned value +// which promises that: +// - The final value will not be null. +// - The final size of the set value will be at least the provided minimum value. +// +// This unknown value refinement allows Terraform to validate more of the configuration during plan +// and evaluate conditional logic in meta-arguments such as "count". +func WillHaveSizeAtLeast(minVal int) planmodifier.Set { + return willHaveSizeAtLeastModifier{ + min: minVal, + } +} + +type willHaveSizeAtLeastModifier struct { + min int +} + +func (m willHaveSizeAtLeastModifier) Description(_ context.Context) string { + return fmt.Sprintf("Promises the value of this attribute will contain at least %d elements once it becomes known", m.min) +} + +func (m willHaveSizeAtLeastModifier) MarkdownDescription(_ context.Context) string { + return fmt.Sprintf("Promises the value of this attribute will contain at least %d elements once it becomes known", m.min) +} + +func (m willHaveSizeAtLeastModifier) PlanModifySet(ctx context.Context, req planmodifier.SetRequest, resp *planmodifier.SetResponse) { + // 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.PlanValue.RefineWithLengthLowerBound(int64(m.min)) +} diff --git a/resource/schema/setplanmodifier/will_have_size_at_least_test.go b/resource/schema/setplanmodifier/will_have_size_at_least_test.go new file mode 100644 index 00000000..98e9f40e --- /dev/null +++ b/resource/schema/setplanmodifier/will_have_size_at_least_test.go @@ -0,0 +1,105 @@ +// 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/types" +) + +func TestWillHaveSizeAtLeastModifierPlanModifySet(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + minVal int + request planmodifier.SetRequest + expected *planmodifier.SetResponse + }{ + "known-plan": { + minVal: 5, + request: planmodifier.SetRequest{ + StateValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("hello"), types.StringValue("world")}), + PlanValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("hello"), types.StringValue("world"), types.StringValue("!")}), + ConfigValue: types.SetNull(types.StringType), + }, + expected: &planmodifier.SetResponse{ + PlanValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("hello"), types.StringValue("world"), types.StringValue("!")}), + }, + }, + "unknown-config": { + // this is the situation in which a user is + // interpolating into a field. We want that to still + // show up as unknown (with no refinement), 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 + minVal: 5, + request: planmodifier.SetRequest{ + StateValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("hello"), types.StringValue("world"), types.StringValue("!")}), + PlanValue: types.SetUnknown(types.StringType), + ConfigValue: types.SetUnknown(types.StringType), + }, + expected: &planmodifier.SetResponse{ + PlanValue: types.SetUnknown(types.StringType), + }, + }, + "unknown-plan-null-state": { + minVal: 5, + request: planmodifier.SetRequest{ + StateValue: types.SetNull(types.StringType), + PlanValue: types.SetUnknown(types.StringType), + ConfigValue: types.SetNull(types.StringType), + }, + expected: &planmodifier.SetResponse{ + PlanValue: types.SetUnknown(types.StringType).RefineWithLengthLowerBound(5), + }, + }, + "unknown-plan-non-null-state": { + minVal: 3, + request: planmodifier.SetRequest{ + StateValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("hello"), types.StringValue("world"), types.StringValue("!")}), + PlanValue: types.SetUnknown(types.StringType), + ConfigValue: types.SetNull(types.StringType), + }, + expected: &planmodifier.SetResponse{ + PlanValue: types.SetUnknown(types.StringType).RefineWithLengthLowerBound(3), + }, + }, + "unknown-plan-preserve-existing-refinement": { + minVal: 2, + request: planmodifier.SetRequest{ + StateValue: types.SetNull(types.StringType), + PlanValue: types.SetUnknown(types.StringType).RefineWithLengthUpperBound(6), + ConfigValue: types.SetNull(types.StringType), + }, + expected: &planmodifier.SetResponse{ + PlanValue: types.SetUnknown(types.StringType).RefineWithLengthUpperBound(6).RefineWithLengthLowerBound(2), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.SetResponse{ + PlanValue: testCase.request.PlanValue, + } + + setplanmodifier.WillHaveSizeAtLeast(testCase.minVal).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/will_have_size_at_most.go b/resource/schema/setplanmodifier/will_have_size_at_most.go new file mode 100644 index 00000000..14e0171f --- /dev/null +++ b/resource/schema/setplanmodifier/will_have_size_at_most.go @@ -0,0 +1,50 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package setplanmodifier + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// WillHaveSizeAtMost returns a plan modifier that will add a refinement to an unknown planned value +// which promises that: +// - The final value will not be null. +// - The final size of the set value will be at most the provided maximum value. +// +// This unknown value refinement allows Terraform to validate more of the configuration during plan +// and evaluate conditional logic in meta-arguments such as "count". +func WillHaveSizeAtMost(maxVal int) planmodifier.Set { + return willHaveSizeAtMostModifier{ + max: maxVal, + } +} + +type willHaveSizeAtMostModifier struct { + max int +} + +func (m willHaveSizeAtMostModifier) Description(_ context.Context) string { + return fmt.Sprintf("Promises the value of this attribute will contain at most %d elements once it becomes known", m.max) +} + +func (m willHaveSizeAtMostModifier) MarkdownDescription(_ context.Context) string { + return fmt.Sprintf("Promises the value of this attribute will contain at most %d elements once it becomes known", m.max) +} + +func (m willHaveSizeAtMostModifier) PlanModifySet(ctx context.Context, req planmodifier.SetRequest, resp *planmodifier.SetResponse) { + // 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.PlanValue.RefineWithLengthUpperBound(int64(m.max)) +} diff --git a/resource/schema/setplanmodifier/will_have_size_at_most_test.go b/resource/schema/setplanmodifier/will_have_size_at_most_test.go new file mode 100644 index 00000000..f85ec0ec --- /dev/null +++ b/resource/schema/setplanmodifier/will_have_size_at_most_test.go @@ -0,0 +1,105 @@ +// 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/types" +) + +func TestWillHaveSizeAtMostModifierPlanModifySet(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + maxVal int + request planmodifier.SetRequest + expected *planmodifier.SetResponse + }{ + "known-plan": { + maxVal: 10, + request: planmodifier.SetRequest{ + StateValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("hello"), types.StringValue("world")}), + PlanValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("hello"), types.StringValue("world"), types.StringValue("!")}), + ConfigValue: types.SetNull(types.StringType), + }, + expected: &planmodifier.SetResponse{ + PlanValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("hello"), types.StringValue("world"), types.StringValue("!")}), + }, + }, + "unknown-config": { + // this is the situation in which a user is + // interpolating into a field. We want that to still + // show up as unknown (with no refinement), 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 + maxVal: 10, + request: planmodifier.SetRequest{ + StateValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("hello"), types.StringValue("world"), types.StringValue("!")}), + PlanValue: types.SetUnknown(types.StringType), + ConfigValue: types.SetUnknown(types.StringType), + }, + expected: &planmodifier.SetResponse{ + PlanValue: types.SetUnknown(types.StringType), + }, + }, + "unknown-plan-null-state": { + maxVal: 10, + request: planmodifier.SetRequest{ + StateValue: types.SetNull(types.StringType), + PlanValue: types.SetUnknown(types.StringType), + ConfigValue: types.SetNull(types.StringType), + }, + expected: &planmodifier.SetResponse{ + PlanValue: types.SetUnknown(types.StringType).RefineWithLengthUpperBound(10), + }, + }, + "unknown-plan-non-null-state": { + maxVal: 4, + request: planmodifier.SetRequest{ + StateValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("hello"), types.StringValue("world"), types.StringValue("!")}), + PlanValue: types.SetUnknown(types.StringType), + ConfigValue: types.SetNull(types.StringType), + }, + expected: &planmodifier.SetResponse{ + PlanValue: types.SetUnknown(types.StringType).RefineWithLengthUpperBound(4), + }, + }, + "unknown-plan-preserve-existing-refinement": { + maxVal: 6, + request: planmodifier.SetRequest{ + StateValue: types.SetNull(types.StringType), + PlanValue: types.SetUnknown(types.StringType).RefineWithLengthLowerBound(2), + ConfigValue: types.SetNull(types.StringType), + }, + expected: &planmodifier.SetResponse{ + PlanValue: types.SetUnknown(types.StringType).RefineWithLengthLowerBound(2).RefineWithLengthUpperBound(6), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.SetResponse{ + PlanValue: testCase.request.PlanValue, + } + + setplanmodifier.WillHaveSizeAtMost(testCase.maxVal).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/will_have_size_between.go b/resource/schema/setplanmodifier/will_have_size_between.go new file mode 100644 index 00000000..e7f56fc8 --- /dev/null +++ b/resource/schema/setplanmodifier/will_have_size_between.go @@ -0,0 +1,55 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package setplanmodifier + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// WillHaveSizeBetween returns a plan modifier that will add a refinement to an unknown planned value +// which promises that: +// - The final value will not be null. +// - The final size of the set value will be at least the provided minimum value. +// - The final size of the set value will be at most the provided maximum value. +// +// This unknown value refinement allows Terraform to validate more of the configuration during plan +// and evaluate conditional logic in meta-arguments such as "count". +func WillHaveSizeBetween(minVal, maxVal int) planmodifier.Set { + return willHaveSizeBetweenModifier{ + min: minVal, + max: maxVal, + } +} + +type willHaveSizeBetweenModifier struct { + min int + max int +} + +func (m willHaveSizeBetweenModifier) Description(_ context.Context) string { + return fmt.Sprintf("Promises the value of this attribute will contain at least %d elements and at most %d elements once it becomes known", m.min, m.max) +} + +func (m willHaveSizeBetweenModifier) MarkdownDescription(_ context.Context) string { + return fmt.Sprintf("Promises the value of this attribute will contain at least %d elements and at most %d elements once it becomes known", m.min, m.max) +} + +func (m willHaveSizeBetweenModifier) PlanModifySet(ctx context.Context, req planmodifier.SetRequest, resp *planmodifier.SetResponse) { + // 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.PlanValue. + RefineWithLengthLowerBound(int64(m.min)). + RefineWithLengthUpperBound(int64(m.max)) +} diff --git a/resource/schema/setplanmodifier/will_have_size_between_test.go b/resource/schema/setplanmodifier/will_have_size_between_test.go new file mode 100644 index 00000000..ddc89da5 --- /dev/null +++ b/resource/schema/setplanmodifier/will_have_size_between_test.go @@ -0,0 +1,111 @@ +// 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/types" +) + +func TestWillHaveSizeBetweenModifierPlanModifySet(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + minVal int + maxVal int + request planmodifier.SetRequest + expected *planmodifier.SetResponse + }{ + "known-plan": { + minVal: 5, + maxVal: 10, + request: planmodifier.SetRequest{ + StateValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("hello"), types.StringValue("world")}), + PlanValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("hello"), types.StringValue("world"), types.StringValue("!")}), + ConfigValue: types.SetNull(types.StringType), + }, + expected: &planmodifier.SetResponse{ + PlanValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("hello"), types.StringValue("world"), types.StringValue("!")}), + }, + }, + "unknown-config": { + // this is the situation in which a user is + // interpolating into a field. We want that to still + // show up as unknown (with no refinement), 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 + minVal: 5, + maxVal: 10, + request: planmodifier.SetRequest{ + StateValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("hello"), types.StringValue("world"), types.StringValue("!")}), + PlanValue: types.SetUnknown(types.StringType), + ConfigValue: types.SetUnknown(types.StringType), + }, + expected: &planmodifier.SetResponse{ + PlanValue: types.SetUnknown(types.StringType), + }, + }, + "unknown-plan-null-state": { + minVal: 5, + maxVal: 10, + request: planmodifier.SetRequest{ + StateValue: types.SetNull(types.StringType), + PlanValue: types.SetUnknown(types.StringType), + ConfigValue: types.SetNull(types.StringType), + }, + expected: &planmodifier.SetResponse{ + PlanValue: types.SetUnknown(types.StringType).RefineWithLengthLowerBound(5).RefineWithLengthUpperBound(10), + }, + }, + "unknown-plan-non-null-state": { + minVal: 3, + maxVal: 4, + request: planmodifier.SetRequest{ + StateValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("hello"), types.StringValue("world"), types.StringValue("!")}), + PlanValue: types.SetUnknown(types.StringType), + ConfigValue: types.SetNull(types.StringType), + }, + expected: &planmodifier.SetResponse{ + PlanValue: types.SetUnknown(types.StringType).RefineWithLengthLowerBound(3).RefineWithLengthUpperBound(4), + }, + }, + "unknown-plan-preserve-existing-refinement": { + minVal: 2, + maxVal: 6, + request: planmodifier.SetRequest{ + StateValue: types.SetNull(types.StringType), + PlanValue: types.SetUnknown(types.StringType).RefineAsNotNull(), + ConfigValue: types.SetNull(types.StringType), + }, + expected: &planmodifier.SetResponse{ + PlanValue: types.SetUnknown(types.StringType).RefineAsNotNull().RefineWithLengthLowerBound(2).RefineWithLengthUpperBound(6), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.SetResponse{ + PlanValue: testCase.request.PlanValue, + } + + setplanmodifier.WillHaveSizeBetween(testCase.minVal, testCase.maxVal).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/will_not_be_null.go b/resource/schema/setplanmodifier/will_not_be_null.go new file mode 100644 index 00000000..b00d3063 --- /dev/null +++ b/resource/schema/setplanmodifier/will_not_be_null.go @@ -0,0 +1,50 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package setplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// WillNotBeNull returns a plan modifier that will add a refinement to an unknown planned value +// which promises that the final value will not be null. +// +// This unknown value refinement allows Terraform to validate more of the configuration during plan +// and evaluate conditional logic in meta-arguments such as "count": +// +// resource "examplecloud_thing" "b" { +// // Will successfully evaluate during plan with a "not null" refinement on "set_attribute" +// count = examplecloud_thing.a.set_attribute != null ? 1 : 0 +// +// // .. resource config +// } +func WillNotBeNull() planmodifier.Set { + return willNotBeNullModifier{} +} + +type willNotBeNullModifier struct{} + +func (m willNotBeNullModifier) Description(_ context.Context) string { + return "Promises the value of this attribute will not be null once it becomes known" +} + +func (m willNotBeNullModifier) MarkdownDescription(_ context.Context) string { + return "Promises the value of this attribute will not be null once it becomes known" +} + +func (m willNotBeNullModifier) PlanModifySet(ctx context.Context, req planmodifier.SetRequest, resp *planmodifier.SetResponse) { + // 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.PlanValue.RefineAsNotNull() +} diff --git a/resource/schema/setplanmodifier/will_not_be_null_test.go b/resource/schema/setplanmodifier/will_not_be_null_test.go new file mode 100644 index 00000000..ded51d1e --- /dev/null +++ b/resource/schema/setplanmodifier/will_not_be_null_test.go @@ -0,0 +1,99 @@ +// 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/types" +) + +func TestWillNotBeNullModifierPlanModifySet(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + request planmodifier.SetRequest + expected *planmodifier.SetResponse + }{ + "known-plan": { + request: planmodifier.SetRequest{ + StateValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("hello"), types.StringValue("world")}), + PlanValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("hello"), types.StringValue("world"), types.StringValue("!")}), + ConfigValue: types.SetNull(types.StringType), + }, + expected: &planmodifier.SetResponse{ + PlanValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("hello"), types.StringValue("world"), types.StringValue("!")}), + }, + }, + "unknown-config": { + // this is the situation in which a user is + // interpolating into a field. We want that to still + // show up as unknown (with no refinement), 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("hello"), types.StringValue("world"), types.StringValue("!")}), + PlanValue: types.SetUnknown(types.StringType), + ConfigValue: types.SetUnknown(types.StringType), + }, + expected: &planmodifier.SetResponse{ + PlanValue: types.SetUnknown(types.StringType), + }, + }, + "unknown-plan-null-state": { + request: planmodifier.SetRequest{ + StateValue: types.SetNull(types.StringType), + PlanValue: types.SetUnknown(types.StringType), + ConfigValue: types.SetNull(types.StringType), + }, + expected: &planmodifier.SetResponse{ + PlanValue: types.SetUnknown(types.StringType).RefineAsNotNull(), + }, + }, + "unknown-plan-non-null-state": { + request: planmodifier.SetRequest{ + StateValue: types.SetValueMust(types.StringType, []attr.Value{types.StringValue("hello"), types.StringValue("world"), types.StringValue("!")}), + PlanValue: types.SetUnknown(types.StringType), + ConfigValue: types.SetNull(types.StringType), + }, + expected: &planmodifier.SetResponse{ + PlanValue: types.SetUnknown(types.StringType).RefineAsNotNull(), + }, + }, + "unknown-plan-preserve-existing-refinement": { + request: planmodifier.SetRequest{ + StateValue: types.SetNull(types.StringType), + PlanValue: types.SetUnknown(types.StringType).RefineWithLengthLowerBound(10), + ConfigValue: types.SetNull(types.StringType), + }, + expected: &planmodifier.SetResponse{ + PlanValue: types.SetUnknown(types.StringType).RefineAsNotNull().RefineWithLengthLowerBound(10), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.SetResponse{ + PlanValue: testCase.request.PlanValue, + } + + setplanmodifier.WillNotBeNull().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/stringplanmodifier/will_have_prefix.go b/resource/schema/stringplanmodifier/will_have_prefix.go new file mode 100644 index 00000000..492222ad --- /dev/null +++ b/resource/schema/stringplanmodifier/will_have_prefix.go @@ -0,0 +1,51 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package stringplanmodifier + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// WillHavePrefix returns a plan modifier that will add a refinement to an unknown planned value +// which promises that: +// - The final value will not be null. +// - The final value will have the provided string prefix. +// +// This unknown value refinement allows Terraform to validate more of the configuration during plan +// and evaluate conditional logic in meta-arguments such as "count". String prefixes that exceed 256 +// characters in length will be truncated and empty string prefixes will be ignored. +func WillHavePrefix(prefix string) planmodifier.String { + return willHavePrefixModifier{ + prefix: prefix, + } +} + +type willHavePrefixModifier struct { + prefix string +} + +func (m willHavePrefixModifier) Description(_ context.Context) string { + return fmt.Sprintf("Promises the value of this attribute will have the prefix %q once it becomes known", m.prefix) +} + +func (m willHavePrefixModifier) MarkdownDescription(_ context.Context) string { + return fmt.Sprintf("Promises the value of this attribute will have the prefix %q once it becomes known", m.prefix) +} + +func (m willHavePrefixModifier) PlanModifyString(ctx context.Context, req planmodifier.StringRequest, resp *planmodifier.StringResponse) { + // 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.PlanValue.RefineWithPrefix(m.prefix) +} diff --git a/resource/schema/stringplanmodifier/will_have_prefix_test.go b/resource/schema/stringplanmodifier/will_have_prefix_test.go new file mode 100644 index 00000000..98e39256 --- /dev/null +++ b/resource/schema/stringplanmodifier/will_have_prefix_test.go @@ -0,0 +1,104 @@ +// 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/types" +) + +func TestWillHavePrefixModifierPlanModifyString(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + prefix string + request planmodifier.StringRequest + expected *planmodifier.StringResponse + }{ + "known-plan": { + prefix: "test:123:", + request: planmodifier.StringRequest{ + StateValue: types.StringValue("other"), + PlanValue: types.StringValue("test"), + ConfigValue: types.StringNull(), + }, + expected: &planmodifier.StringResponse{ + PlanValue: types.StringValue("test"), + }, + }, + "unknown-config": { + // this is the situation in which a user is + // interpolating into a field. We want that to still + // show up as unknown (with no refinement), 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 + prefix: "test:123:", + request: planmodifier.StringRequest{ + StateValue: types.StringValue("test"), + PlanValue: types.StringUnknown(), + ConfigValue: types.StringUnknown(), + }, + expected: &planmodifier.StringResponse{ + PlanValue: types.StringUnknown(), + }, + }, + "unknown-plan-null-state": { + prefix: "test:123:", + request: planmodifier.StringRequest{ + StateValue: types.StringNull(), + PlanValue: types.StringUnknown(), + ConfigValue: types.StringNull(), + }, + expected: &planmodifier.StringResponse{ + PlanValue: types.StringUnknown().RefineWithPrefix("test:123:"), + }, + }, + "unknown-plan-non-null-state": { + prefix: "test:123:", + request: planmodifier.StringRequest{ + StateValue: types.StringValue("test"), + PlanValue: types.StringUnknown(), + ConfigValue: types.StringNull(), + }, + expected: &planmodifier.StringResponse{ + PlanValue: types.StringUnknown().RefineWithPrefix("test:123:"), + }, + }, + "unknown-plan-preserve-existing-refinement": { + prefix: "test:123:", + request: planmodifier.StringRequest{ + StateValue: types.StringNull(), + PlanValue: types.StringUnknown().RefineAsNotNull(), + ConfigValue: types.StringNull(), + }, + expected: &planmodifier.StringResponse{ + PlanValue: types.StringUnknown().RefineAsNotNull().RefineWithPrefix("test:123:"), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.StringResponse{ + PlanValue: testCase.request.PlanValue, + } + + stringplanmodifier.WillHavePrefix(testCase.prefix).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/will_not_be_null.go b/resource/schema/stringplanmodifier/will_not_be_null.go new file mode 100644 index 00000000..40ab997b --- /dev/null +++ b/resource/schema/stringplanmodifier/will_not_be_null.go @@ -0,0 +1,50 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package stringplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// WillNotBeNull returns a plan modifier that will add a refinement to an unknown planned value +// which promises that the final value will not be null. +// +// This unknown value refinement allows Terraform to validate more of the configuration during plan +// and evaluate conditional logic in meta-arguments such as "count": +// +// resource "examplecloud_thing" "b" { +// // Will successfully evaluate during plan with a "not null" refinement on "string_attribute" +// count = examplecloud_thing.a.string_attribute != null ? 1 : 0 +// +// // .. resource config +// } +func WillNotBeNull() planmodifier.String { + return willNotBeNullModifier{} +} + +type willNotBeNullModifier struct{} + +func (m willNotBeNullModifier) Description(_ context.Context) string { + return "Promises the value of this attribute will not be null once it becomes known" +} + +func (m willNotBeNullModifier) MarkdownDescription(_ context.Context) string { + return "Promises the value of this attribute will not be null once it becomes known" +} + +func (m willNotBeNullModifier) PlanModifyString(ctx context.Context, req planmodifier.StringRequest, resp *planmodifier.StringResponse) { + // 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.PlanValue.RefineAsNotNull() +} diff --git a/resource/schema/stringplanmodifier/will_not_be_null_test.go b/resource/schema/stringplanmodifier/will_not_be_null_test.go new file mode 100644 index 00000000..93aa5e05 --- /dev/null +++ b/resource/schema/stringplanmodifier/will_not_be_null_test.go @@ -0,0 +1,98 @@ +// 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/types" +) + +func TestWillNotBeNullModifierPlanModifyString(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + request planmodifier.StringRequest + expected *planmodifier.StringResponse + }{ + "known-plan": { + request: planmodifier.StringRequest{ + StateValue: types.StringValue("other"), + PlanValue: types.StringValue("test"), + ConfigValue: types.StringNull(), + }, + expected: &planmodifier.StringResponse{ + PlanValue: types.StringValue("test"), + }, + }, + "unknown-config": { + // this is the situation in which a user is + // interpolating into a field. We want that to still + // show up as unknown (with no refinement), 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{ + StateValue: types.StringValue("test"), + PlanValue: types.StringUnknown(), + ConfigValue: types.StringUnknown(), + }, + expected: &planmodifier.StringResponse{ + PlanValue: types.StringUnknown(), + }, + }, + "unknown-plan-null-state": { + request: planmodifier.StringRequest{ + StateValue: types.StringNull(), + PlanValue: types.StringUnknown(), + ConfigValue: types.StringNull(), + }, + expected: &planmodifier.StringResponse{ + PlanValue: types.StringUnknown().RefineAsNotNull(), + }, + }, + "unknown-plan-non-null-state": { + request: planmodifier.StringRequest{ + StateValue: types.StringValue("test"), + PlanValue: types.StringUnknown(), + ConfigValue: types.StringNull(), + }, + expected: &planmodifier.StringResponse{ + PlanValue: types.StringUnknown().RefineAsNotNull(), + }, + }, + "unknown-plan-preserve-existing-refinement": { + request: planmodifier.StringRequest{ + StateValue: types.StringNull(), + PlanValue: types.StringUnknown().RefineWithPrefix("preserve me"), + ConfigValue: types.StringNull(), + }, + expected: &planmodifier.StringResponse{ + PlanValue: types.StringUnknown().RefineAsNotNull().RefineWithPrefix("preserve me"), + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp := &planmodifier.StringResponse{ + PlanValue: testCase.request.PlanValue, + } + + stringplanmodifier.WillNotBeNull().PlanModifyString(context.Background(), testCase.request, resp) + + if diff := cmp.Diff(testCase.expected, resp); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/types/basetypes/bool_type.go b/types/basetypes/bool_type.go index 9bdc30bb..edb14dec 100644 --- a/types/basetypes/bool_type.go +++ b/types/basetypes/bool_type.go @@ -10,6 +10,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-go/tftypes" + tfrefinement "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" ) // BoolTypable extends attr.Type for bool types. @@ -61,7 +62,29 @@ func (t BoolType) ValueFromBool(_ context.Context, v BoolValue) (BoolValuable, d // consume the data with. func (t BoolType) ValueFromTerraform(ctx context.Context, in tftypes.Value) (attr.Value, error) { if !in.IsKnown() { - return NewBoolUnknown(), nil + unknownVal := NewBoolUnknown() + refinements := in.Refinements() + + if len(refinements) == 0 { + return unknownVal, nil + } + + for _, refn := range refinements { + switch refnVal := refn.(type) { + case tfrefinement.Nullness: + if !refnVal.Nullness() { + unknownVal = unknownVal.RefineAsNotNull() + } else { + // This scenario shouldn't occur, as Terraform should have already collapsed an + // unknown value with a definitely null refinement into a known null value. However, + // the protocol encoding does support this refinement value, so we'll also just collapse + // it into a known null value here. + return NewBoolNull(), nil + } + } + } + + return unknownVal, nil } if in.IsNull() { diff --git a/types/basetypes/bool_type_test.go b/types/basetypes/bool_type_test.go index 8d55d830..dd38617a 100644 --- a/types/basetypes/bool_type_test.go +++ b/types/basetypes/bool_type_test.go @@ -9,6 +9,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-go/tftypes" + tfrefinement "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" ) func TestBoolTypeValueFromTerraform(t *testing.T) { @@ -32,6 +33,12 @@ func TestBoolTypeValueFromTerraform(t *testing.T) { input: tftypes.NewValue(tftypes.Bool, tftypes.UnknownValue), expectation: NewBoolUnknown(), }, + "unknown-with-notnull-refinement": { + input: tftypes.NewValue(tftypes.Bool, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + }), + expectation: NewBoolUnknown().RefineAsNotNull(), + }, "null": { input: tftypes.NewValue(tftypes.Bool, nil), expectation: NewBoolNull(), @@ -61,7 +68,7 @@ func TestBoolTypeValueFromTerraform(t *testing.T) { // expectations, we're good return } - if err == nil && test.expectedErr != "" { + if test.expectedErr != "" { t.Errorf("Expected error to be %q, didn't get an error", test.expectedErr) return } @@ -77,3 +84,23 @@ func TestBoolTypeValueFromTerraform(t *testing.T) { }) } } + +func TestBoolTypeValueFromTerraform_RefinementNullCollapse(t *testing.T) { + t.Parallel() + + // This shouldn't happen, but this test ensures that if we receive this kind of refinement, that we will + // convert it to a known null value. + input := tftypes.NewValue(tftypes.Bool, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(true), + }) + expectation := NewBoolNull() + + got, err := BoolType{}.ValueFromTerraform(context.Background(), input) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + if !got.Equal(expectation) { + t.Errorf("Expected %+v, got %+v", expectation, got) + } +} diff --git a/types/basetypes/bool_value.go b/types/basetypes/bool_value.go index aa10b398..523b9ca3 100644 --- a/types/basetypes/bool_value.go +++ b/types/basetypes/bool_value.go @@ -9,12 +9,15 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types/refinement" "github.com/hashicorp/terraform-plugin-go/tftypes" + tfrefinement "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" ) var ( - _ BoolValuable = BoolValue{} + _ BoolValuable = BoolValue{} + _ attr.ValueWithNotNullRefinement = BoolValue{} ) // BoolValuable extends attr.Value for boolean value types. @@ -84,6 +87,10 @@ type BoolValue struct { // value contains the known value, if not null or unknown. value bool + + // refinements represents the unknown value refinement data associated with this Value. + // This field is only populated for unknown values. + refinements refinement.Refinements } // Type returns a BoolType. @@ -103,7 +110,20 @@ func (b BoolValue) ToTerraformValue(_ context.Context) (tftypes.Value, error) { case attr.ValueStateNull: return tftypes.NewValue(tftypes.Bool, nil), nil case attr.ValueStateUnknown: - return tftypes.NewValue(tftypes.Bool, tftypes.UnknownValue), nil + if len(b.refinements) == 0 { + return tftypes.NewValue(tftypes.Bool, tftypes.UnknownValue), nil + } + + unknownValRefinements := make(tfrefinement.Refinements, 0) + for _, refn := range b.refinements { + switch refn.(type) { + case refinement.NotNull: + unknownValRefinements[tfrefinement.KeyNullness] = tfrefinement.NewNullness(false) + } + } + unknownVal := tftypes.NewValue(tftypes.Bool, tftypes.UnknownValue) + + return unknownVal.Refine(unknownValRefinements), nil default: panic(fmt.Sprintf("unhandled Bool state in ToTerraformValue: %s", b.state)) } @@ -121,6 +141,14 @@ func (b BoolValue) Equal(other attr.Value) bool { return false } + if len(b.refinements) != len(o.refinements) { + return false + } + + if len(b.refinements) > 0 && !b.refinements.Equal(o.refinements) { + return false + } + if b.state != attr.ValueStateKnown { return true } @@ -143,7 +171,11 @@ func (b BoolValue) IsUnknown() bool { // and is intended for logging and error reporting. func (b BoolValue) String() string { if b.IsUnknown() { - return attr.UnknownValueString + if len(b.refinements) == 0 { + return attr.UnknownValueString + } + + return fmt.Sprintf("", b.refinements.String()) } if b.IsNull() { @@ -173,3 +205,48 @@ func (b BoolValue) ValueBoolPointer() *bool { func (b BoolValue) ToBoolValue(context.Context) (BoolValue, diag.Diagnostics) { return b, nil } + +// RefineAsNotNull will return a new unknown BoolValue that includes a value refinement that: +// - Indicates the bool value will not be null once it becomes known. +// +// If the provided BoolValue is null or known, then the BoolValue will be returned unchanged. +func (b BoolValue) RefineAsNotNull() BoolValue { + if !b.IsUnknown() { + return b + } + + newRefinements := make(refinement.Refinements, len(b.refinements)) + for i, refn := range b.refinements { + newRefinements[i] = refn + } + + newRefinements[refinement.KeyNotNull] = refinement.NewNotNull() + + newUnknownVal := NewBoolUnknown() + newUnknownVal.refinements = newRefinements + + return newUnknownVal +} + +// NotNullRefinement returns value refinement data and a boolean indicating if a NotNull refinement +// exists on the given BoolValue. If a BoolValue contains a NotNull refinement, this indicates +// that the bool is unknown, but the eventual known value will not be null. +// +// A NotNull value refinement can be added to an unknown value via the `RefineAsNotNull` method. +func (b BoolValue) NotNullRefinement() (*refinement.NotNull, bool) { + if !b.IsUnknown() { + return nil, false + } + + refn, ok := b.refinements[refinement.KeyNotNull] + if !ok { + return nil, false + } + + notNullRefn, ok := refn.(refinement.NotNull) + if !ok { + return nil, false + } + + return ¬NullRefn, true +} diff --git a/types/basetypes/bool_value_test.go b/types/basetypes/bool_value_test.go index 00100689..5183820a 100644 --- a/types/basetypes/bool_value_test.go +++ b/types/basetypes/bool_value_test.go @@ -9,7 +9,9 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types/refinement" "github.com/hashicorp/terraform-plugin-go/tftypes" + tfrefinement "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" ) func TestBoolValueToTerraformValue(t *testing.T) { @@ -32,6 +34,12 @@ func TestBoolValueToTerraformValue(t *testing.T) { input: NewBoolUnknown(), expectation: tftypes.NewValue(tftypes.Bool, tftypes.UnknownValue), }, + "unknown-with-notnull-refinement": { + input: NewBoolUnknown().RefineAsNotNull(), + expectation: tftypes.NewValue(tftypes.Bool, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + }), + }, "null": { input: NewBoolNull(), expectation: tftypes.NewValue(tftypes.Bool, nil), @@ -154,6 +162,16 @@ func TestBoolValueEqual(t *testing.T) { candidate: NewBoolUnknown(), expectation: false, }, + "unknown-unknown-with-notnull-refinement": { + input: NewBoolUnknown(), + candidate: NewBoolUnknown().RefineAsNotNull(), + expectation: false, + }, + "unknowns-with-matching-notnull-refinements": { + input: NewBoolUnknown().RefineAsNotNull(), + candidate: NewBoolUnknown().RefineAsNotNull(), + expectation: true, + }, } for name, test := range tests { name, test := name, test @@ -264,6 +282,10 @@ func TestBoolValueString(t *testing.T) { input: NewBoolUnknown(), expectation: "", }, + "unknown-with-notnull-refinement": { + input: NewBoolUnknown().RefineAsNotNull(), + expectation: "", + }, } for name, test := range tests { @@ -390,3 +412,56 @@ func TestNewBoolPointerValue(t *testing.T) { }) } } + +func TestBoolValue_NotNullRefinement(t *testing.T) { + t.Parallel() + + type testCase struct { + input BoolValue + expectedRefnVal refinement.Refinement + expectedFound bool + } + tests := map[string]testCase{ + "known-ignored": { + input: NewBoolValue(true).RefineAsNotNull(), + expectedFound: false, + }, + "null-ignored": { + input: NewBoolNull().RefineAsNotNull(), + expectedFound: false, + }, + "unknown-no-refinement": { + input: NewBoolUnknown(), + expectedFound: false, + }, + "unknown-with-notnull-refinement": { + input: NewBoolUnknown().RefineAsNotNull(), + expectedRefnVal: refinement.NewNotNull(), + expectedFound: true, + }, + } + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, found := test.input.NotNullRefinement() + if found != test.expectedFound { + t.Fatalf("Expected refinement exists to be: %t, got: %t", test.expectedFound, found) + } + + if got == nil && test.expectedRefnVal == nil { + // Success! + return + } + + if got == nil && test.expectedRefnVal != nil { + t.Fatalf("Expected refinement data: <%+v>, got: nil", test.expectedRefnVal) + } + + if diff := cmp.Diff(*got, test.expectedRefnVal); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/types/basetypes/float32_type.go b/types/basetypes/float32_type.go index 77d35286..a1a427ae 100644 --- a/types/basetypes/float32_type.go +++ b/types/basetypes/float32_type.go @@ -10,6 +10,7 @@ import ( "math/big" "github.com/hashicorp/terraform-plugin-go/tftypes" + tfrefinement "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" @@ -65,7 +66,41 @@ func (t Float32Type) ValueFromFloat32(_ context.Context, v Float32Value) (Float3 // consume the data with. func (t Float32Type) ValueFromTerraform(ctx context.Context, in tftypes.Value) (attr.Value, error) { if !in.IsKnown() { - return NewFloat32Unknown(), nil + unknownVal := NewFloat32Unknown() + refinements := in.Refinements() + + if len(refinements) == 0 { + return unknownVal, nil + } + + for _, refn := range refinements { + switch refnVal := refn.(type) { + case tfrefinement.Nullness: + if !refnVal.Nullness() { + unknownVal = unknownVal.RefineAsNotNull() + } else { + // This scenario shouldn't occur, as Terraform should have already collapsed an + // unknown value with a definitely null refinement into a known null value. However, + // the protocol encoding does support this refinement value, so we'll also just collapse + // it into a known null value here. + return NewFloat32Null(), nil + } + case tfrefinement.NumberLowerBound: + boundVal, err := tryBigFloatAsFloat32(ctx, refnVal.LowerBound()) + if err != nil { + return nil, fmt.Errorf("error parsing lower bound refinement: %w", err) + } + unknownVal = unknownVal.RefineWithLowerBound(boundVal, refnVal.IsInclusive()) + case tfrefinement.NumberUpperBound: + boundVal, err := tryBigFloatAsFloat32(ctx, refnVal.UpperBound()) + if err != nil { + return nil, fmt.Errorf("error parsing upper bound refinement: %w", err) + } + unknownVal = unknownVal.RefineWithUpperBound(boundVal, refnVal.IsInclusive()) + } + } + + return unknownVal, nil } if in.IsNull() { @@ -79,6 +114,25 @@ func (t Float32Type) ValueFromTerraform(ctx context.Context, in tftypes.Value) ( return nil, err } + _, err = tryBigFloatAsFloat32(ctx, bigF) + if err != nil { + return nil, err + } + + // Underlying *big.Float values are not exposed with helper functions, so creating Float32Value via struct literal + return Float32Value{ + state: attr.ValueStateKnown, + value: bigF, + }, nil +} + +// ValueType returns the Value type. +func (t Float32Type) ValueType(_ context.Context) attr.Value { + // This Value does not need to be valid. + return Float32Value{} +} + +func tryBigFloatAsFloat32(ctx context.Context, bigF *big.Float) (float32, error) { f, accuracy := bigF.Float32() f64, f64accuracy := bigF.Float64() @@ -90,24 +144,14 @@ func (t Float32Type) ValueFromTerraform(ctx context.Context, in tftypes.Value) ( // Underflow // Reference: https://pkg.go.dev/math/big#Float.Float32 if f == 0 && accuracy != big.Exact { - return nil, fmt.Errorf("Value %s cannot be represented as a 32-bit floating point.", bigF) + return 0, fmt.Errorf("Value %s cannot be represented as a 32-bit floating point.", bigF) } // Overflow // Reference: https://pkg.go.dev/math/big#Float.Float32 if math.IsInf(float64(f), 0) { - return nil, fmt.Errorf("Value %s cannot be represented as a 32-bit floating point.", bigF) + return 0, fmt.Errorf("Value %s cannot be represented as a 32-bit floating point.", bigF) } - // Underlying *big.Float values are not exposed with helper functions, so creating Float32Value via struct literal - return Float32Value{ - state: attr.ValueStateKnown, - value: bigF, - }, nil -} - -// ValueType returns the Value type. -func (t Float32Type) ValueType(_ context.Context) attr.Value { - // This Value does not need to be valid. - return Float32Value{} + return f, nil } diff --git a/types/basetypes/float32_type_test.go b/types/basetypes/float32_type_test.go index a409b35c..fa304a56 100644 --- a/types/basetypes/float32_type_test.go +++ b/types/basetypes/float32_type_test.go @@ -12,6 +12,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform-plugin-go/tftypes" + tfrefinement "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" "github.com/hashicorp/terraform-plugin-framework/attr" ) @@ -39,6 +40,34 @@ func TestFloat32TypeValueFromTerraform(t *testing.T) { input: tftypes.NewValue(tftypes.Number, tftypes.UnknownValue), expectation: NewFloat32Unknown(), }, + "unknown-with-notnull-refinement": { + input: tftypes.NewValue(tftypes.Number, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + }), + expectation: NewFloat32Unknown().RefineAsNotNull(), + }, + "unknown-with-lowerbound-refinement": { + input: tftypes.NewValue(tftypes.Number, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + tfrefinement.KeyNumberLowerBound: tfrefinement.NewNumberLowerBound(big.NewFloat(1.23), true), + }), + expectation: NewFloat32Unknown().RefineWithLowerBound(1.23, true), + }, + "unknown-with-upperbound-refinement": { + input: tftypes.NewValue(tftypes.Number, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + tfrefinement.KeyNumberUpperBound: tfrefinement.NewNumberUpperBound(big.NewFloat(4.56), false), + }), + expectation: NewFloat32Unknown().RefineWithUpperBound(4.56, false), + }, + "unknown-with-both-bound-refinements": { + input: tftypes.NewValue(tftypes.Number, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + tfrefinement.KeyNumberLowerBound: tfrefinement.NewNumberLowerBound(big.NewFloat(1.23), true), + tfrefinement.KeyNumberUpperBound: tfrefinement.NewNumberUpperBound(big.NewFloat(4.56), false), + }), + expectation: NewFloat32Unknown().RefineWithLowerBound(1.23, true).RefineWithUpperBound(4.56, false), + }, "null": { input: tftypes.NewValue(tftypes.Number, nil), expectation: NewFloat32Null(), @@ -120,7 +149,7 @@ func TestFloat32TypeValueFromTerraform(t *testing.T) { // expectations, we're good return } - if err == nil && test.expectedErr != "" { + if test.expectedErr != "" { t.Errorf("Expected error to be %q, didn't get an error", test.expectedErr) return } @@ -136,3 +165,23 @@ func TestFloat32TypeValueFromTerraform(t *testing.T) { }) } } + +func TestFloat32TypeValueFromTerraform_RefinementNullCollapse(t *testing.T) { + t.Parallel() + + // This shouldn't happen, but this test ensures that if we receive this kind of refinement, that we will + // convert it to a known null value. + input := tftypes.NewValue(tftypes.Number, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(true), + }) + expectation := NewFloat32Null() + + got, err := Float32Type{}.ValueFromTerraform(context.Background(), input) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + if !got.Equal(expectation) { + t.Errorf("Expected %+v, got %+v", expectation, got) + } +} diff --git a/types/basetypes/float32_value.go b/types/basetypes/float32_value.go index 1085b849..223b46d2 100644 --- a/types/basetypes/float32_value.go +++ b/types/basetypes/float32_value.go @@ -12,11 +12,14 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types/refinement" + tfrefinement "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" ) var ( _ Float32Valuable = Float32Value{} _ Float32ValuableWithSemanticEquals = Float32Value{} + _ attr.ValueWithNotNullRefinement = Float32Value{} ) // Float32Valuable extends attr.Value for float32 value types. @@ -87,6 +90,10 @@ type Float32Value struct { // value contains the known value, if not null or unknown. value *big.Float + + // refinements represents the unknown value refinement data associated with this Value. + // This field is only populated for unknown values. + refinements refinement.Refinements } // Float32SemanticEquals returns true if the given Float32Value is semantically equal to the current Float32Value. @@ -123,6 +130,14 @@ func (f Float32Value) Equal(other attr.Value) bool { return false } + if len(f.refinements) != len(o.refinements) { + return false + } + + if len(f.refinements) > 0 && !f.refinements.Equal(o.refinements) { + return false + } + if f.state != attr.ValueStateKnown { return true } @@ -147,7 +162,26 @@ func (f Float32Value) ToTerraformValue(ctx context.Context) (tftypes.Value, erro case attr.ValueStateNull: return tftypes.NewValue(tftypes.Number, nil), nil case attr.ValueStateUnknown: - return tftypes.NewValue(tftypes.Number, tftypes.UnknownValue), nil + if len(f.refinements) == 0 { + return tftypes.NewValue(tftypes.Number, tftypes.UnknownValue), nil + } + + unknownValRefinements := make(tfrefinement.Refinements, 0) + for _, refn := range f.refinements { + switch refnVal := refn.(type) { + case refinement.NotNull: + unknownValRefinements[tfrefinement.KeyNullness] = tfrefinement.NewNullness(false) + case refinement.Float32LowerBound: + lowerBound := big.NewFloat(float64(refnVal.LowerBound())) + unknownValRefinements[tfrefinement.KeyNumberLowerBound] = tfrefinement.NewNumberLowerBound(lowerBound, refnVal.IsInclusive()) + case refinement.Float32UpperBound: + upperBound := big.NewFloat(float64(refnVal.UpperBound())) + unknownValRefinements[tfrefinement.KeyNumberUpperBound] = tfrefinement.NewNumberUpperBound(upperBound, refnVal.IsInclusive()) + } + } + unknownVal := tftypes.NewValue(tftypes.Number, tftypes.UnknownValue) + + return unknownVal.Refine(unknownValRefinements), nil default: panic(fmt.Sprintf("unhandled Float32 state in ToTerraformValue: %s", f.state)) } @@ -173,7 +207,11 @@ func (f Float32Value) IsUnknown() bool { // and is intended for logging and error reporting. func (f Float32Value) String() string { if f.IsUnknown() { - return attr.UnknownValueString + if len(f.refinements) == 0 { + return attr.UnknownValueString + } + + return fmt.Sprintf("", f.refinements.String()) } if f.IsNull() { @@ -216,3 +254,146 @@ func (f Float32Value) ValueFloat32Pointer() *float32 { func (f Float32Value) ToFloat32Value(context.Context) (Float32Value, diag.Diagnostics) { return f, nil } + +// RefineAsNotNull will return an unknown Float32Value that includes a value refinement that: +// - Indicates the float32 value will not be null once it becomes known. +// +// If the provided Float32Value is null or known, then the Float32Value will be returned unchanged. +func (f Float32Value) RefineAsNotNull() Float32Value { + if !f.IsUnknown() { + return f + } + + newRefinements := make(refinement.Refinements, len(f.refinements)) + for i, refn := range f.refinements { + newRefinements[i] = refn + } + + newRefinements[refinement.KeyNotNull] = refinement.NewNotNull() + + newUnknownVal := NewFloat32Unknown() + newUnknownVal.refinements = newRefinements + + return newUnknownVal +} + +// RefineWithLowerBound will return an unknown Float32Value that includes a value refinement that: +// - Indicates the float32 value will not be null once it becomes known. +// - Indicates the float32 value will not be less than the float32 provided (lowerBound) once it becomes known. +// +// If the provided Float32Value is null or known, then the Float32Value will be returned unchanged. +func (f Float32Value) RefineWithLowerBound(lowerBound float32, inclusive bool) Float32Value { + if !f.IsUnknown() { + return f + } + + newRefinements := make(refinement.Refinements, len(f.refinements)) + for i, refn := range f.refinements { + newRefinements[i] = refn + } + + newRefinements[refinement.KeyNotNull] = refinement.NewNotNull() + newRefinements[refinement.KeyNumberLowerBound] = refinement.NewFloat32LowerBound(lowerBound, inclusive) + + newUnknownVal := NewFloat32Unknown() + newUnknownVal.refinements = newRefinements + + return newUnknownVal +} + +// RefineWithUpperBound will return an unknown Float32Value that includes a value refinement that: +// - Indicates the float32 value will not be null once it becomes known. +// - Indicates the float32 value will not be greater than the float32 provided (upperBound) once it becomes known. +// +// If the provided Float32Value is null or known, then the Float32Value will be returned unchanged. +func (f Float32Value) RefineWithUpperBound(upperBound float32, inclusive bool) Float32Value { + if !f.IsUnknown() { + return f + } + + newRefinements := make(refinement.Refinements, len(f.refinements)) + for i, refn := range f.refinements { + newRefinements[i] = refn + } + + newRefinements[refinement.KeyNotNull] = refinement.NewNotNull() + newRefinements[refinement.KeyNumberUpperBound] = refinement.NewFloat32UpperBound(upperBound, inclusive) + + newUnknownVal := NewFloat32Unknown() + newUnknownVal.refinements = newRefinements + + return newUnknownVal +} + +// NotNullRefinement returns value refinement data and a boolean indicating if a NotNull refinement +// exists on the given Float32Value. If an Float32Value contains a NotNull refinement, this indicates that +// the float32 value is unknown, but the eventual known value will not be null. +// +// A NotNull value refinement can be added to an unknown value via the `RefineAsNotNull` method. +func (f Float32Value) NotNullRefinement() (*refinement.NotNull, bool) { + if !f.IsUnknown() { + return nil, false + } + + refn, ok := f.refinements[refinement.KeyNotNull] + if !ok { + return nil, false + } + + notNullRefn, ok := refn.(refinement.NotNull) + if !ok { + return nil, false + } + + return ¬NullRefn, true +} + +// LowerBoundRefinement returns value refinement data and a boolean indicating if a Float32LowerBound refinement +// exists on the given Float32Value. If an Float32Value contains a Float32LowerBound refinement, this indicates that +// the float32 value is unknown, but the eventual known value will not be less than the specified float32 value +// (either inclusive or exclusive) once it becomes known. The returned boolean should be checked before accessing +// refinement data. +// +// An Float32LowerBound value refinement can be added to an unknown value via the `RefineWithLowerBound` method. +func (f Float32Value) LowerBoundRefinement() (*refinement.Float32LowerBound, bool) { + if !f.IsUnknown() { + return nil, false + } + + refn, ok := f.refinements[refinement.KeyNumberLowerBound] + if !ok { + return nil, false + } + + lowerBoundRefn, ok := refn.(refinement.Float32LowerBound) + if !ok { + return nil, false + } + + return &lowerBoundRefn, true +} + +// UpperBoundRefinement returns value refinement data and a boolean indicating if a Float32UpperBound refinement +// exists on the given Float32Value. If an Float32Value contains a Float32UpperBound refinement, this indicates that +// the float32 value is unknown, but the eventual known value will not be greater than the specified float32 value +// (either inclusive or exclusive) once it becomes known. The returned boolean should be checked before accessing +// refinement data. +// +// A Float32UpperBound value refinement can be added to an unknown value via the `RefineWithUpperBound` method. +func (f Float32Value) UpperBoundRefinement() (*refinement.Float32UpperBound, bool) { + if !f.IsUnknown() { + return nil, false + } + + refn, ok := f.refinements[refinement.KeyNumberUpperBound] + if !ok { + return nil, false + } + + upperBoundRefn, ok := refn.(refinement.Float32UpperBound) + if !ok { + return nil, false + } + + return &upperBoundRefn, true +} diff --git a/types/basetypes/float32_value_test.go b/types/basetypes/float32_value_test.go index 73781613..8c068c9d 100644 --- a/types/basetypes/float32_value_test.go +++ b/types/basetypes/float32_value_test.go @@ -11,9 +11,11 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform-plugin-go/tftypes" + tfrefinement "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types/refinement" ) func TestFloat32ValueToTerraformValue(t *testing.T) { @@ -38,6 +40,34 @@ func TestFloat32ValueToTerraformValue(t *testing.T) { input: NewFloat32Unknown(), expectation: tftypes.NewValue(tftypes.Number, tftypes.UnknownValue), }, + "unknown-with-notnull-refinement": { + input: NewFloat32Unknown().RefineAsNotNull(), + expectation: tftypes.NewValue(tftypes.Number, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + }), + }, + "unknown-with-lower-bound-refinement": { + input: NewFloat32Unknown().RefineWithLowerBound(1.23, true), + expectation: tftypes.NewValue(tftypes.Number, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + tfrefinement.KeyNumberLowerBound: tfrefinement.NewNumberLowerBound(big.NewFloat(float64(float32(1.23))), true), + }), + }, + "unknown-with-upper-bound-refinement": { + input: NewFloat32Unknown().RefineWithUpperBound(4.56, false), + expectation: tftypes.NewValue(tftypes.Number, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + tfrefinement.KeyNumberUpperBound: tfrefinement.NewNumberUpperBound(big.NewFloat(float64(float32(4.56))), false), + }), + }, + "unknown-with-both-bound-refinements": { + input: NewFloat32Unknown().RefineWithLowerBound(1.23, true).RefineWithUpperBound(4.56, false), + expectation: tftypes.NewValue(tftypes.Number, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + tfrefinement.KeyNumberLowerBound: tfrefinement.NewNumberLowerBound(big.NewFloat(float64(float32(1.23))), true), + tfrefinement.KeyNumberUpperBound: tfrefinement.NewNumberUpperBound(big.NewFloat(float64(float32(4.56))), false), + }), + }, "null": { input: NewFloat32Null(), expectation: tftypes.NewValue(tftypes.Number, nil), @@ -192,6 +222,71 @@ func TestFloat32ValueEqual(t *testing.T) { candidate: NewFloat32Unknown(), expectation: true, }, + "unknown-unknown-with-notnull-refinement": { + input: NewFloat32Unknown(), + candidate: NewFloat32Unknown().RefineAsNotNull(), + expectation: false, + }, + "unknown-unknown-with-lowerbound-refinement": { + input: NewFloat32Unknown(), + candidate: NewFloat32Unknown().RefineWithLowerBound(1.23, true), + expectation: false, + }, + "unknown-unknown-with-upperbound-refinement": { + input: NewFloat32Unknown(), + candidate: NewFloat32Unknown().RefineWithUpperBound(4.56, false), + expectation: false, + }, + "unknowns-with-matching-notnull-refinements": { + input: NewFloat32Unknown().RefineAsNotNull(), + candidate: NewFloat32Unknown().RefineAsNotNull(), + expectation: true, + }, + "unknowns-with-matching-lowerbound-refinements": { + input: NewFloat32Unknown().RefineWithLowerBound(1.23, true), + candidate: NewFloat32Unknown().RefineWithLowerBound(1.23, true), + expectation: true, + }, + "unknowns-with-different-lowerbound-refinements": { + input: NewFloat32Unknown().RefineWithLowerBound(1.23, true), + candidate: NewFloat32Unknown().RefineWithLowerBound(1.24, true), + expectation: false, + }, + "unknowns-with-different-lowerbound-refinements-inclusive": { + input: NewFloat32Unknown().RefineWithLowerBound(1.23, true), + candidate: NewFloat32Unknown().RefineWithLowerBound(1.23, false), + expectation: false, + }, + "unknowns-with-matching-upperbound-refinements": { + input: NewFloat32Unknown().RefineWithUpperBound(4.56, true), + candidate: NewFloat32Unknown().RefineWithUpperBound(4.56, true), + expectation: true, + }, + "unknowns-with-different-upperbound-refinements": { + input: NewFloat32Unknown().RefineWithUpperBound(4.56, true), + candidate: NewFloat32Unknown().RefineWithUpperBound(4.57, true), + expectation: false, + }, + "unknowns-with-different-upperbound-refinements-inclusive": { + input: NewFloat32Unknown().RefineWithUpperBound(4.56, true), + candidate: NewFloat32Unknown().RefineWithUpperBound(4.56, false), + expectation: false, + }, + "unknowns-with-matching-both-bound-refinements": { + input: NewFloat32Unknown().RefineWithLowerBound(1.23, true).RefineWithUpperBound(4.56, true), + candidate: NewFloat32Unknown().RefineWithLowerBound(1.23, true).RefineWithUpperBound(4.56, true), + expectation: true, + }, + "unknowns-with-different-both-bound-refinements": { + input: NewFloat32Unknown().RefineWithLowerBound(1.23, true).RefineWithUpperBound(4.56, true), + candidate: NewFloat32Unknown().RefineWithLowerBound(1.23, true).RefineWithUpperBound(4.57, true), + expectation: false, + }, + "unknowns-with-different-both-bound-refinements-inclusive": { + input: NewFloat32Unknown().RefineWithLowerBound(1.23, true).RefineWithUpperBound(4.56, true), + candidate: NewFloat32Unknown().RefineWithLowerBound(1.23, true).RefineWithUpperBound(4.56, false), + expectation: false, + }, "unknown-null": { input: NewFloat32Unknown(), candidate: NewFloat32Null(), @@ -341,6 +436,22 @@ func TestFloat32ValueString(t *testing.T) { input: NewFloat32Unknown(), expectation: "", }, + "unknown-with-notnull-refinement": { + input: NewFloat32Unknown().RefineAsNotNull(), + expectation: "", + }, + "unknown-with-lowerbound-refinement": { + input: NewFloat32Unknown().RefineWithLowerBound(1.23, true), + expectation: ``, + }, + "unknown-with-upperbound-refinement": { + input: NewFloat32Unknown().RefineWithUpperBound(4.56, false), + expectation: ``, + }, + "unknown-with-both-bound-refinements": { + input: NewFloat32Unknown().RefineWithLowerBound(1.23, true).RefineWithUpperBound(4.56, false), + expectation: ``, + }, "null": { input: NewFloat32Null(), expectation: "", @@ -543,3 +654,162 @@ func TestFloat32ValueFloat32SemanticEquals(t *testing.T) { }) } } + +func TestFloat32Value_NotNullRefinement(t *testing.T) { + t.Parallel() + + type testCase struct { + input Float32Value + expectedRefnVal refinement.Refinement + expectedFound bool + } + tests := map[string]testCase{ + "known-ignored": { + input: NewFloat32Value(4.56).RefineAsNotNull(), + expectedFound: false, + }, + "null-ignored": { + input: NewFloat32Null().RefineAsNotNull(), + expectedFound: false, + }, + "unknown-no-refinement": { + input: NewFloat32Unknown(), + expectedFound: false, + }, + "unknown-with-notnull-refinement": { + input: NewFloat32Unknown().RefineAsNotNull(), + expectedRefnVal: refinement.NewNotNull(), + expectedFound: true, + }, + } + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, found := test.input.NotNullRefinement() + if found != test.expectedFound { + t.Fatalf("Expected refinement exists to be: %t, got: %t", test.expectedFound, found) + } + + if got == nil && test.expectedRefnVal == nil { + // Success! + return + } + + if got == nil && test.expectedRefnVal != nil { + t.Fatalf("Expected refinement data: <%+v>, got: nil", test.expectedRefnVal) + } + + if diff := cmp.Diff(*got, test.expectedRefnVal); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestFloat32Value_LowerBoundRefinement(t *testing.T) { + t.Parallel() + + type testCase struct { + input Float32Value + expectedRefnVal refinement.Refinement + expectedFound bool + } + tests := map[string]testCase{ + "known-ignored": { + input: NewFloat32Value(4.56).RefineWithLowerBound(1.23, true), + expectedFound: false, + }, + "null-ignored": { + input: NewFloat32Null().RefineWithLowerBound(1.23, true), + expectedFound: false, + }, + "unknown-no-refinement": { + input: NewFloat32Unknown(), + expectedFound: false, + }, + "unknown-with-lowerbound-refinement": { + input: NewFloat32Unknown().RefineWithLowerBound(1.23, true), + expectedRefnVal: refinement.NewFloat32LowerBound(1.23, true), + expectedFound: true, + }, + } + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, found := test.input.LowerBoundRefinement() + if found != test.expectedFound { + t.Fatalf("Expected refinement exists to be: %t, got: %t", test.expectedFound, found) + } + + if got == nil && test.expectedRefnVal == nil { + // Success! + return + } + + if got == nil && test.expectedRefnVal != nil { + t.Fatalf("Expected refinement data: <%+v>, got: nil", test.expectedRefnVal) + } + + if diff := cmp.Diff(*got, test.expectedRefnVal); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestFloat32Value_UpperBoundRefinement(t *testing.T) { + t.Parallel() + + type testCase struct { + input Float32Value + expectedRefnVal refinement.Refinement + expectedFound bool + } + tests := map[string]testCase{ + "known-ignored": { + input: NewFloat32Value(4.56).RefineWithUpperBound(1.23, true), + expectedFound: false, + }, + "null-ignored": { + input: NewFloat32Null().RefineWithUpperBound(1.23, true), + expectedFound: false, + }, + "unknown-no-refinement": { + input: NewFloat32Unknown(), + expectedFound: false, + }, + "unknown-with-upperbound-refinement": { + input: NewFloat32Unknown().RefineWithUpperBound(1.23, true), + expectedRefnVal: refinement.NewFloat32UpperBound(1.23, true), + expectedFound: true, + }, + } + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, found := test.input.UpperBoundRefinement() + if found != test.expectedFound { + t.Fatalf("Expected refinement exists to be: %t, got: %t", test.expectedFound, found) + } + + if got == nil && test.expectedRefnVal == nil { + // Success! + return + } + + if got == nil && test.expectedRefnVal != nil { + t.Fatalf("Expected refinement data: <%+v>, got: nil", test.expectedRefnVal) + } + + if diff := cmp.Diff(*got, test.expectedRefnVal); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/types/basetypes/float64_type.go b/types/basetypes/float64_type.go index a783201e..fc13fd60 100644 --- a/types/basetypes/float64_type.go +++ b/types/basetypes/float64_type.go @@ -10,6 +10,7 @@ import ( "math/big" "github.com/hashicorp/terraform-plugin-go/tftypes" + tfrefinement "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/attr/xattr" @@ -129,7 +130,41 @@ func (t Float64Type) ValueFromFloat64(_ context.Context, v Float64Value) (Float6 // consume the data with. func (t Float64Type) ValueFromTerraform(ctx context.Context, in tftypes.Value) (attr.Value, error) { if !in.IsKnown() { - return NewFloat64Unknown(), nil + unknownVal := NewFloat64Unknown() + refinements := in.Refinements() + + if len(refinements) == 0 { + return unknownVal, nil + } + + for _, refn := range refinements { + switch refnVal := refn.(type) { + case tfrefinement.Nullness: + if !refnVal.Nullness() { + unknownVal = unknownVal.RefineAsNotNull() + } else { + // This scenario shouldn't occur, as Terraform should have already collapsed an + // unknown value with a definitely null refinement into a known null value. However, + // the protocol encoding does support this refinement value, so we'll also just collapse + // it into a known null value here. + return NewFloat64Null(), nil + } + case tfrefinement.NumberLowerBound: + boundVal, err := tryBigFloatAsFloat64(refnVal.LowerBound()) + if err != nil { + return nil, fmt.Errorf("error parsing lower bound refinement: %w", err) + } + unknownVal = unknownVal.RefineWithLowerBound(boundVal, refnVal.IsInclusive()) + case tfrefinement.NumberUpperBound: + boundVal, err := tryBigFloatAsFloat64(refnVal.UpperBound()) + if err != nil { + return nil, fmt.Errorf("error parsing upper bound refinement: %w", err) + } + unknownVal = unknownVal.RefineWithUpperBound(boundVal, refnVal.IsInclusive()) + } + } + + return unknownVal, nil } if in.IsNull() { @@ -143,18 +178,9 @@ func (t Float64Type) ValueFromTerraform(ctx context.Context, in tftypes.Value) ( return nil, err } - f, accuracy := bigF.Float64() - - // Underflow - // Reference: https://pkg.go.dev/math/big#Float.Float64 - if f == 0 && accuracy != big.Exact { - return nil, fmt.Errorf("Value %s cannot be represented as a 64-bit floating point.", bigF) - } - - // Overflow - // Reference: https://pkg.go.dev/math/big#Float.Float64 - if math.IsInf(f, 0) { - return nil, fmt.Errorf("Value %s cannot be represented as a 64-bit floating point.", bigF) + _, err = tryBigFloatAsFloat64(bigF) + if err != nil { + return nil, err } // Underlying *big.Float values are not exposed with helper functions, so creating Float64Value via struct literal @@ -169,3 +195,21 @@ func (t Float64Type) ValueType(_ context.Context) attr.Value { // This Value does not need to be valid. return Float64Value{} } + +func tryBigFloatAsFloat64(bigF *big.Float) (float64, error) { + f, accuracy := bigF.Float64() + + // Underflow + // Reference: https://pkg.go.dev/math/big#Float.Float64 + if f == 0 && accuracy != big.Exact { + return 0, fmt.Errorf("Value %s cannot be represented as a 64-bit floating point.", bigF) + } + + // Overflow + // Reference: https://pkg.go.dev/math/big#Float.Float64 + if math.IsInf(f, 0) { + return 0, fmt.Errorf("Value %s cannot be represented as a 64-bit floating point.", bigF) + } + + return f, nil +} diff --git a/types/basetypes/float64_type_test.go b/types/basetypes/float64_type_test.go index 4f103525..8a28ee85 100644 --- a/types/basetypes/float64_type_test.go +++ b/types/basetypes/float64_type_test.go @@ -15,6 +15,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-go/tftypes" + tfrefinement "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" ) func TestFloat64TypeValidate(t *testing.T) { @@ -127,6 +128,34 @@ func TestFloat64TypeValueFromTerraform(t *testing.T) { input: tftypes.NewValue(tftypes.Number, tftypes.UnknownValue), expectation: NewFloat64Unknown(), }, + "unknown-with-notnull-refinement": { + input: tftypes.NewValue(tftypes.Number, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + }), + expectation: NewFloat64Unknown().RefineAsNotNull(), + }, + "unknown-with-lowerbound-refinement": { + input: tftypes.NewValue(tftypes.Number, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + tfrefinement.KeyNumberLowerBound: tfrefinement.NewNumberLowerBound(big.NewFloat(1.23), true), + }), + expectation: NewFloat64Unknown().RefineWithLowerBound(1.23, true), + }, + "unknown-with-upperbound-refinement": { + input: tftypes.NewValue(tftypes.Number, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + tfrefinement.KeyNumberUpperBound: tfrefinement.NewNumberUpperBound(big.NewFloat(4.56), false), + }), + expectation: NewFloat64Unknown().RefineWithUpperBound(4.56, false), + }, + "unknown-with-both-bound-refinements": { + input: tftypes.NewValue(tftypes.Number, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + tfrefinement.KeyNumberLowerBound: tfrefinement.NewNumberLowerBound(big.NewFloat(1.23), true), + tfrefinement.KeyNumberUpperBound: tfrefinement.NewNumberUpperBound(big.NewFloat(4.56), false), + }), + expectation: NewFloat64Unknown().RefineWithLowerBound(1.23, true).RefineWithUpperBound(4.56, false), + }, "null": { input: tftypes.NewValue(tftypes.Number, nil), expectation: NewFloat64Null(), @@ -208,7 +237,7 @@ func TestFloat64TypeValueFromTerraform(t *testing.T) { // expectations, we're good return } - if err == nil && test.expectedErr != "" { + if test.expectedErr != "" { t.Errorf("Expected error to be %q, didn't get an error", test.expectedErr) return } @@ -224,3 +253,23 @@ func TestFloat64TypeValueFromTerraform(t *testing.T) { }) } } + +func TestFloat64TypeValueFromTerraform_RefinementNullCollapse(t *testing.T) { + t.Parallel() + + // This shouldn't happen, but this test ensures that if we receive this kind of refinement, that we will + // convert it to a known null value. + input := tftypes.NewValue(tftypes.Number, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(true), + }) + expectation := NewFloat64Null() + + got, err := Float64Type{}.ValueFromTerraform(context.Background(), input) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + if !got.Equal(expectation) { + t.Errorf("Expected %+v, got %+v", expectation, got) + } +} diff --git a/types/basetypes/float64_value.go b/types/basetypes/float64_value.go index fb9c19a5..b1800b9e 100644 --- a/types/basetypes/float64_value.go +++ b/types/basetypes/float64_value.go @@ -12,11 +12,14 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types/refinement" + tfrefinement "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" ) var ( _ Float64Valuable = Float64Value{} _ Float64ValuableWithSemanticEquals = Float64Value{} + _ attr.ValueWithNotNullRefinement = Float64Value{} ) // Float64Valuable extends attr.Value for float64 value types. @@ -93,6 +96,10 @@ type Float64Value struct { // value contains the known value, if not null or unknown. value *big.Float + + // refinements represents the unknown value refinement data associated with this Value. + // This field is only populated for unknown values. + refinements refinement.Refinements } // Float64SemanticEquals returns true if the given Float64Value is semantically equal to the current Float64Value. @@ -129,6 +136,14 @@ func (f Float64Value) Equal(other attr.Value) bool { return false } + if len(f.refinements) != len(o.refinements) { + return false + } + + if len(f.refinements) > 0 && !f.refinements.Equal(o.refinements) { + return false + } + if f.state != attr.ValueStateKnown { return true } @@ -153,7 +168,26 @@ func (f Float64Value) ToTerraformValue(ctx context.Context) (tftypes.Value, erro case attr.ValueStateNull: return tftypes.NewValue(tftypes.Number, nil), nil case attr.ValueStateUnknown: - return tftypes.NewValue(tftypes.Number, tftypes.UnknownValue), nil + if len(f.refinements) == 0 { + return tftypes.NewValue(tftypes.Number, tftypes.UnknownValue), nil + } + + unknownValRefinements := make(tfrefinement.Refinements, 0) + for _, refn := range f.refinements { + switch refnVal := refn.(type) { + case refinement.NotNull: + unknownValRefinements[tfrefinement.KeyNullness] = tfrefinement.NewNullness(false) + case refinement.Float64LowerBound: + lowerBound := big.NewFloat(refnVal.LowerBound()) + unknownValRefinements[tfrefinement.KeyNumberLowerBound] = tfrefinement.NewNumberLowerBound(lowerBound, refnVal.IsInclusive()) + case refinement.Float64UpperBound: + upperBound := big.NewFloat(refnVal.UpperBound()) + unknownValRefinements[tfrefinement.KeyNumberUpperBound] = tfrefinement.NewNumberUpperBound(upperBound, refnVal.IsInclusive()) + } + } + unknownVal := tftypes.NewValue(tftypes.Number, tftypes.UnknownValue) + + return unknownVal.Refine(unknownValRefinements), nil default: panic(fmt.Sprintf("unhandled Float64 state in ToTerraformValue: %s", f.state)) } @@ -179,7 +213,11 @@ func (f Float64Value) IsUnknown() bool { // and is intended for logging and error reporting. func (f Float64Value) String() string { if f.IsUnknown() { - return attr.UnknownValueString + if len(f.refinements) == 0 { + return attr.UnknownValueString + } + + return fmt.Sprintf("", f.refinements.String()) } if f.IsNull() { @@ -221,3 +259,146 @@ func (f Float64Value) ValueFloat64Pointer() *float64 { func (f Float64Value) ToFloat64Value(context.Context) (Float64Value, diag.Diagnostics) { return f, nil } + +// RefineAsNotNull will return an unknown Float64Value that includes a value refinement that: +// - Indicates the float64 value will not be null once it becomes known. +// +// If the provided Float64Value is null or known, then the Float64Value will be returned unchanged. +func (f Float64Value) RefineAsNotNull() Float64Value { + if !f.IsUnknown() { + return f + } + + newRefinements := make(refinement.Refinements, len(f.refinements)) + for i, refn := range f.refinements { + newRefinements[i] = refn + } + + newRefinements[refinement.KeyNotNull] = refinement.NewNotNull() + + newUnknownVal := NewFloat64Unknown() + newUnknownVal.refinements = newRefinements + + return newUnknownVal +} + +// RefineWithLowerBound will return an unknown Float64Value that includes a value refinement that: +// - Indicates the float64 value will not be null once it becomes known. +// - Indicates the float64 value will not be less than the float64 provided (lowerBound) once it becomes known. +// +// If the provided Float64Value is null or known, then the Float64Value will be returned unchanged. +func (f Float64Value) RefineWithLowerBound(lowerBound float64, inclusive bool) Float64Value { + if !f.IsUnknown() { + return f + } + + newRefinements := make(refinement.Refinements, len(f.refinements)) + for i, refn := range f.refinements { + newRefinements[i] = refn + } + + newRefinements[refinement.KeyNotNull] = refinement.NewNotNull() + newRefinements[refinement.KeyNumberLowerBound] = refinement.NewFloat64LowerBound(lowerBound, inclusive) + + newUnknownVal := NewFloat64Unknown() + newUnknownVal.refinements = newRefinements + + return newUnknownVal +} + +// RefineWithUpperBound will return an unknown Float64Value that includes a value refinement that: +// - Indicates the float64 value will not be null once it becomes known. +// - Indicates the float64 value will not be greater than the float64 provided (upperBound) once it becomes known. +// +// If the provided Float64Value is null or known, then the Float64Value will be returned unchanged. +func (f Float64Value) RefineWithUpperBound(upperBound float64, inclusive bool) Float64Value { + if !f.IsUnknown() { + return f + } + + newRefinements := make(refinement.Refinements, len(f.refinements)) + for i, refn := range f.refinements { + newRefinements[i] = refn + } + + newRefinements[refinement.KeyNotNull] = refinement.NewNotNull() + newRefinements[refinement.KeyNumberUpperBound] = refinement.NewFloat64UpperBound(upperBound, inclusive) + + newUnknownVal := NewFloat64Unknown() + newUnknownVal.refinements = newRefinements + + return newUnknownVal +} + +// NotNullRefinement returns value refinement data and a boolean indicating if a NotNull refinement +// exists on the given Float64Value. If an Float64Value contains a NotNull refinement, this indicates that +// the float64 value is unknown, but the eventual known value will not be null. +// +// A NotNull value refinement can be added to an unknown value via the `RefineAsNotNull` method. +func (f Float64Value) NotNullRefinement() (*refinement.NotNull, bool) { + if !f.IsUnknown() { + return nil, false + } + + refn, ok := f.refinements[refinement.KeyNotNull] + if !ok { + return nil, false + } + + notNullRefn, ok := refn.(refinement.NotNull) + if !ok { + return nil, false + } + + return ¬NullRefn, true +} + +// LowerBoundRefinement returns value refinement data and a boolean indicating if a Float64LowerBound refinement +// exists on the given Float64Value. If an Float64Value contains a Float64LowerBound refinement, this indicates that +// the float64 value is unknown, but the eventual known value will not be less than the specified float64 value +// (either inclusive or exclusive) once it becomes known. The returned boolean should be checked before accessing +// refinement data. +// +// An Float64LowerBound value refinement can be added to an unknown value via the `RefineWithLowerBound` method. +func (f Float64Value) LowerBoundRefinement() (*refinement.Float64LowerBound, bool) { + if !f.IsUnknown() { + return nil, false + } + + refn, ok := f.refinements[refinement.KeyNumberLowerBound] + if !ok { + return nil, false + } + + lowerBoundRefn, ok := refn.(refinement.Float64LowerBound) + if !ok { + return nil, false + } + + return &lowerBoundRefn, true +} + +// UpperBoundRefinement returns value refinement data and a boolean indicating if a Float64UpperBound refinement +// exists on the given Float64Value. If an Float64Value contains a Float64UpperBound refinement, this indicates that +// the float64 value is unknown, but the eventual known value will not be greater than the specified float64 value +// (either inclusive or exclusive) once it becomes known. The returned boolean should be checked before accessing +// refinement data. +// +// A Float64UpperBound value refinement can be added to an unknown value via the `RefineWithUpperBound` method. +func (f Float64Value) UpperBoundRefinement() (*refinement.Float64UpperBound, bool) { + if !f.IsUnknown() { + return nil, false + } + + refn, ok := f.refinements[refinement.KeyNumberUpperBound] + if !ok { + return nil, false + } + + upperBoundRefn, ok := refn.(refinement.Float64UpperBound) + if !ok { + return nil, false + } + + return &upperBoundRefn, true +} diff --git a/types/basetypes/float64_value_test.go b/types/basetypes/float64_value_test.go index ca9dca6b..fca416f8 100644 --- a/types/basetypes/float64_value_test.go +++ b/types/basetypes/float64_value_test.go @@ -12,7 +12,9 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types/refinement" "github.com/hashicorp/terraform-plugin-go/tftypes" + tfrefinement "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" ) // testMustParseFloat parses a string into a *big.Float similar to cty and @@ -50,6 +52,34 @@ func TestFloat64ValueToTerraformValue(t *testing.T) { input: NewFloat64Unknown(), expectation: tftypes.NewValue(tftypes.Number, tftypes.UnknownValue), }, + "unknown-with-notnull-refinement": { + input: NewFloat64Unknown().RefineAsNotNull(), + expectation: tftypes.NewValue(tftypes.Number, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + }), + }, + "unknown-with-lower-bound-refinement": { + input: NewFloat64Unknown().RefineWithLowerBound(1.23, true), + expectation: tftypes.NewValue(tftypes.Number, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + tfrefinement.KeyNumberLowerBound: tfrefinement.NewNumberLowerBound(big.NewFloat(1.23), true), + }), + }, + "unknown-with-upper-bound-refinement": { + input: NewFloat64Unknown().RefineWithUpperBound(4.56, false), + expectation: tftypes.NewValue(tftypes.Number, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + tfrefinement.KeyNumberUpperBound: tfrefinement.NewNumberUpperBound(big.NewFloat(4.56), false), + }), + }, + "unknown-with-both-bound-refinements": { + input: NewFloat64Unknown().RefineWithLowerBound(1.23, true).RefineWithUpperBound(4.56, false), + expectation: tftypes.NewValue(tftypes.Number, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + tfrefinement.KeyNumberLowerBound: tfrefinement.NewNumberLowerBound(big.NewFloat(1.23), true), + tfrefinement.KeyNumberUpperBound: tfrefinement.NewNumberUpperBound(big.NewFloat(4.56), false), + }), + }, "null": { input: NewFloat64Null(), expectation: tftypes.NewValue(tftypes.Number, nil), @@ -204,6 +234,71 @@ func TestFloat64ValueEqual(t *testing.T) { candidate: NewFloat64Unknown(), expectation: true, }, + "unknown-unknown-with-notnull-refinement": { + input: NewFloat64Unknown(), + candidate: NewFloat64Unknown().RefineAsNotNull(), + expectation: false, + }, + "unknown-unknown-with-lowerbound-refinement": { + input: NewFloat64Unknown(), + candidate: NewFloat64Unknown().RefineWithLowerBound(1.23, true), + expectation: false, + }, + "unknown-unknown-with-upperbound-refinement": { + input: NewFloat64Unknown(), + candidate: NewFloat64Unknown().RefineWithUpperBound(4.56, false), + expectation: false, + }, + "unknowns-with-matching-notnull-refinements": { + input: NewFloat64Unknown().RefineAsNotNull(), + candidate: NewFloat64Unknown().RefineAsNotNull(), + expectation: true, + }, + "unknowns-with-matching-lowerbound-refinements": { + input: NewFloat64Unknown().RefineWithLowerBound(1.23, true), + candidate: NewFloat64Unknown().RefineWithLowerBound(1.23, true), + expectation: true, + }, + "unknowns-with-different-lowerbound-refinements": { + input: NewFloat64Unknown().RefineWithLowerBound(1.23, true), + candidate: NewFloat64Unknown().RefineWithLowerBound(1.24, true), + expectation: false, + }, + "unknowns-with-different-lowerbound-refinements-inclusive": { + input: NewFloat64Unknown().RefineWithLowerBound(1.23, true), + candidate: NewFloat64Unknown().RefineWithLowerBound(1.23, false), + expectation: false, + }, + "unknowns-with-matching-upperbound-refinements": { + input: NewFloat64Unknown().RefineWithUpperBound(4.56, true), + candidate: NewFloat64Unknown().RefineWithUpperBound(4.56, true), + expectation: true, + }, + "unknowns-with-different-upperbound-refinements": { + input: NewFloat64Unknown().RefineWithUpperBound(4.56, true), + candidate: NewFloat64Unknown().RefineWithUpperBound(4.57, true), + expectation: false, + }, + "unknowns-with-different-upperbound-refinements-inclusive": { + input: NewFloat64Unknown().RefineWithUpperBound(4.56, true), + candidate: NewFloat64Unknown().RefineWithUpperBound(4.56, false), + expectation: false, + }, + "unknowns-with-matching-both-bound-refinements": { + input: NewFloat64Unknown().RefineWithLowerBound(1.23, true).RefineWithUpperBound(4.56, true), + candidate: NewFloat64Unknown().RefineWithLowerBound(1.23, true).RefineWithUpperBound(4.56, true), + expectation: true, + }, + "unknowns-with-different-both-bound-refinements": { + input: NewFloat64Unknown().RefineWithLowerBound(1.23, true).RefineWithUpperBound(4.56, true), + candidate: NewFloat64Unknown().RefineWithLowerBound(1.23, true).RefineWithUpperBound(4.57, true), + expectation: false, + }, + "unknowns-with-different-both-bound-refinements-inclusive": { + input: NewFloat64Unknown().RefineWithLowerBound(1.23, true).RefineWithUpperBound(4.56, true), + candidate: NewFloat64Unknown().RefineWithLowerBound(1.23, true).RefineWithUpperBound(4.56, false), + expectation: false, + }, "unknown-null": { input: NewFloat64Unknown(), candidate: NewFloat64Null(), @@ -346,6 +441,22 @@ func TestFloat64ValueString(t *testing.T) { input: NewFloat64Unknown(), expectation: "", }, + "unknown-with-notnull-refinement": { + input: NewFloat64Unknown().RefineAsNotNull(), + expectation: "", + }, + "unknown-with-lowerbound-refinement": { + input: NewFloat64Unknown().RefineWithLowerBound(1.23, true), + expectation: ``, + }, + "unknown-with-upperbound-refinement": { + input: NewFloat64Unknown().RefineWithUpperBound(4.56, false), + expectation: ``, + }, + "unknown-with-both-bound-refinements": { + input: NewFloat64Unknown().RefineWithLowerBound(1.23, true).RefineWithUpperBound(4.56, false), + expectation: ``, + }, "null": { input: NewFloat64Null(), expectation: "", @@ -548,3 +659,162 @@ func TestFloat64ValueFloat64SemanticEquals(t *testing.T) { }) } } + +func TestFloat64Value_NotNullRefinement(t *testing.T) { + t.Parallel() + + type testCase struct { + input Float64Value + expectedRefnVal refinement.Refinement + expectedFound bool + } + tests := map[string]testCase{ + "known-ignored": { + input: NewFloat64Value(4.56).RefineAsNotNull(), + expectedFound: false, + }, + "null-ignored": { + input: NewFloat64Null().RefineAsNotNull(), + expectedFound: false, + }, + "unknown-no-refinement": { + input: NewFloat64Unknown(), + expectedFound: false, + }, + "unknown-with-notnull-refinement": { + input: NewFloat64Unknown().RefineAsNotNull(), + expectedRefnVal: refinement.NewNotNull(), + expectedFound: true, + }, + } + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, found := test.input.NotNullRefinement() + if found != test.expectedFound { + t.Fatalf("Expected refinement exists to be: %t, got: %t", test.expectedFound, found) + } + + if got == nil && test.expectedRefnVal == nil { + // Success! + return + } + + if got == nil && test.expectedRefnVal != nil { + t.Fatalf("Expected refinement data: <%+v>, got: nil", test.expectedRefnVal) + } + + if diff := cmp.Diff(*got, test.expectedRefnVal); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestFloat64Value_LowerBoundRefinement(t *testing.T) { + t.Parallel() + + type testCase struct { + input Float64Value + expectedRefnVal refinement.Refinement + expectedFound bool + } + tests := map[string]testCase{ + "known-ignored": { + input: NewFloat64Value(4.56).RefineWithLowerBound(1.23, true), + expectedFound: false, + }, + "null-ignored": { + input: NewFloat64Null().RefineWithLowerBound(1.23, true), + expectedFound: false, + }, + "unknown-no-refinement": { + input: NewFloat64Unknown(), + expectedFound: false, + }, + "unknown-with-lowerbound-refinement": { + input: NewFloat64Unknown().RefineWithLowerBound(1.23, true), + expectedRefnVal: refinement.NewFloat64LowerBound(1.23, true), + expectedFound: true, + }, + } + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, found := test.input.LowerBoundRefinement() + if found != test.expectedFound { + t.Fatalf("Expected refinement exists to be: %t, got: %t", test.expectedFound, found) + } + + if got == nil && test.expectedRefnVal == nil { + // Success! + return + } + + if got == nil && test.expectedRefnVal != nil { + t.Fatalf("Expected refinement data: <%+v>, got: nil", test.expectedRefnVal) + } + + if diff := cmp.Diff(*got, test.expectedRefnVal); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestFloat64Value_UpperBoundRefinement(t *testing.T) { + t.Parallel() + + type testCase struct { + input Float64Value + expectedRefnVal refinement.Refinement + expectedFound bool + } + tests := map[string]testCase{ + "known-ignored": { + input: NewFloat64Value(4.56).RefineWithUpperBound(1.23, true), + expectedFound: false, + }, + "null-ignored": { + input: NewFloat64Null().RefineWithUpperBound(1.23, true), + expectedFound: false, + }, + "unknown-no-refinement": { + input: NewFloat64Unknown(), + expectedFound: false, + }, + "unknown-with-upperbound-refinement": { + input: NewFloat64Unknown().RefineWithUpperBound(1.23, true), + expectedRefnVal: refinement.NewFloat64UpperBound(1.23, true), + expectedFound: true, + }, + } + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, found := test.input.UpperBoundRefinement() + if found != test.expectedFound { + t.Fatalf("Expected refinement exists to be: %t, got: %t", test.expectedFound, found) + } + + if got == nil && test.expectedRefnVal == nil { + // Success! + return + } + + if got == nil && test.expectedRefnVal != nil { + t.Fatalf("Expected refinement data: <%+v>, got: nil", test.expectedRefnVal) + } + + if diff := cmp.Diff(*got, test.expectedRefnVal); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/types/basetypes/int32_type.go b/types/basetypes/int32_type.go index 3943e88a..c30202df 100644 --- a/types/basetypes/int32_type.go +++ b/types/basetypes/int32_type.go @@ -10,6 +10,7 @@ import ( "math/big" "github.com/hashicorp/terraform-plugin-go/tftypes" + tfrefinement "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" @@ -64,7 +65,43 @@ func (t Int32Type) ValueFromInt32(_ context.Context, v Int32Value) (Int32Valuabl // consume the data with. func (t Int32Type) ValueFromTerraform(ctx context.Context, in tftypes.Value) (attr.Value, error) { if !in.IsKnown() { - return NewInt32Unknown(), nil + unknownVal := NewInt32Unknown() + refinements := in.Refinements() + + if len(refinements) == 0 { + return unknownVal, nil + } + + for _, refn := range refinements { + switch refnVal := refn.(type) { + case tfrefinement.Nullness: + if !refnVal.Nullness() { + unknownVal = unknownVal.RefineAsNotNull() + } else { + // This scenario shouldn't occur, as Terraform should have already collapsed an + // unknown value with a definitely null refinement into a known null value. However, + // the protocol encoding does support this refinement value, so we'll also just collapse + // it into a known null value here. + return NewInt32Null(), nil + } + case tfrefinement.NumberLowerBound: + // TODO: Is it possible for Terraform to create this refinement? Should we chop off the decimal point? + boundVal, err := tryBigFloatToInt32(refnVal.LowerBound()) + if err != nil { + return nil, fmt.Errorf("error parsing lower bound refinement: %w", err) + } + unknownVal = unknownVal.RefineWithLowerBound(boundVal, refnVal.IsInclusive()) + case tfrefinement.NumberUpperBound: + // TODO: Is it possible for Terraform to create this refinement? Should we chop off the decimal point? + boundVal, err := tryBigFloatToInt32(refnVal.UpperBound()) + if err != nil { + return nil, fmt.Errorf("error parsing upper bound refinement: %w", err) + } + unknownVal = unknownVal.RefineWithUpperBound(boundVal, refnVal.IsInclusive()) + } + } + + return unknownVal, nil } if in.IsNull() { @@ -78,25 +115,34 @@ func (t Int32Type) ValueFromTerraform(ctx context.Context, in tftypes.Value) (at return nil, err } + i, err := tryBigFloatToInt32(bigF) + if err != nil { + return nil, err + } + + return NewInt32Value(i), nil +} + +// ValueType returns the Value type. +func (t Int32Type) ValueType(_ context.Context) attr.Value { + // This Value does not need to be valid. + return Int32Value{} +} + +func tryBigFloatToInt32(bigF *big.Float) (int32, error) { if !bigF.IsInt() { - return nil, fmt.Errorf("Value %s is not an integer.", bigF) + return 0, fmt.Errorf("Value %s is not an integer.", bigF) } i, accuracy := bigF.Int64() if accuracy != 0 { - return nil, fmt.Errorf("Value %s cannot be represented as a 32-bit integer.", bigF) + return 0, fmt.Errorf("Value %s cannot be represented as a 32-bit integer.", bigF) } if i < math.MinInt32 || i > math.MaxInt32 { - return nil, fmt.Errorf("Value %s cannot be represented as a 32-bit integer.", bigF) + return 0, fmt.Errorf("Value %s cannot be represented as a 32-bit integer.", bigF) } - return NewInt32Value(int32(i)), nil -} - -// ValueType returns the Value type. -func (t Int32Type) ValueType(_ context.Context) attr.Value { - // This Value does not need to be valid. - return Int32Value{} + return int32(i), nil } diff --git a/types/basetypes/int32_type_test.go b/types/basetypes/int32_type_test.go index af5c8bdd..5414de0d 100644 --- a/types/basetypes/int32_type_test.go +++ b/types/basetypes/int32_type_test.go @@ -6,9 +6,11 @@ package basetypes import ( "context" "math" + "math/big" "testing" "github.com/hashicorp/terraform-plugin-go/tftypes" + tfrefinement "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" "github.com/hashicorp/terraform-plugin-framework/attr" ) @@ -30,6 +32,34 @@ func TestInt32TypeValueFromTerraform(t *testing.T) { input: tftypes.NewValue(tftypes.Number, tftypes.UnknownValue), expectation: NewInt32Unknown(), }, + "unknown-with-notnull-refinement": { + input: tftypes.NewValue(tftypes.Number, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + }), + expectation: NewInt32Unknown().RefineAsNotNull(), + }, + "unknown-with-lowerbound-refinement": { + input: tftypes.NewValue(tftypes.Number, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + tfrefinement.KeyNumberLowerBound: tfrefinement.NewNumberLowerBound(big.NewFloat(10), true), + }), + expectation: NewInt32Unknown().RefineWithLowerBound(10, true), + }, + "unknown-with-upperbound-refinement": { + input: tftypes.NewValue(tftypes.Number, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + tfrefinement.KeyNumberUpperBound: tfrefinement.NewNumberUpperBound(big.NewFloat(100), false), + }), + expectation: NewInt32Unknown().RefineWithUpperBound(100, false), + }, + "unknown-with-both-bound-refinements": { + input: tftypes.NewValue(tftypes.Number, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + tfrefinement.KeyNumberLowerBound: tfrefinement.NewNumberLowerBound(big.NewFloat(10), true), + tfrefinement.KeyNumberUpperBound: tfrefinement.NewNumberUpperBound(big.NewFloat(100), false), + }), + expectation: NewInt32Unknown().RefineWithLowerBound(10, true).RefineWithUpperBound(100, false), + }, "null": { input: tftypes.NewValue(tftypes.Number, nil), expectation: NewInt32Null(), @@ -79,7 +109,7 @@ func TestInt32TypeValueFromTerraform(t *testing.T) { // expectations, we're good return } - if err == nil && test.expectedErr != "" { + if test.expectedErr != "" { t.Errorf("Expected error to be %q, didn't get an error", test.expectedErr) return } @@ -95,3 +125,23 @@ func TestInt32TypeValueFromTerraform(t *testing.T) { }) } } + +func TestInt32TypeValueFromTerraform_RefinementNullCollapse(t *testing.T) { + t.Parallel() + + // This shouldn't happen, but this test ensures that if we receive this kind of refinement, that we will + // convert it to a known null value. + input := tftypes.NewValue(tftypes.Number, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(true), + }) + expectation := NewInt32Null() + + got, err := Int32Type{}.ValueFromTerraform(context.Background(), input) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + if !got.Equal(expectation) { + t.Errorf("Expected %+v, got %+v", expectation, got) + } +} diff --git a/types/basetypes/int32_value.go b/types/basetypes/int32_value.go index 1561cb89..1b8e5547 100644 --- a/types/basetypes/int32_value.go +++ b/types/basetypes/int32_value.go @@ -6,15 +6,19 @@ package basetypes import ( "context" "fmt" + "math/big" "github.com/hashicorp/terraform-plugin-go/tftypes" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types/refinement" + tfrefinement "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" ) var ( - _ Int32Valuable = Int32Value{} + _ Int32Valuable = Int32Value{} + _ attr.ValueWithNotNullRefinement = Int32Value{} ) // Int32Valuable extends attr.Value for int32 value types. @@ -84,6 +88,10 @@ type Int32Value struct { // value contains the known value, if not null or unknown. value int32 + + // refinements represents the unknown value refinement data associated with this Value. + // This field is only populated for unknown values. + refinements refinement.Refinements } // Equal returns true if `other` is an Int32 and has the same value as `i`. @@ -98,6 +106,14 @@ func (i Int32Value) Equal(other attr.Value) bool { return false } + if len(i.refinements) != len(o.refinements) { + return false + } + + if len(i.refinements) > 0 && !i.refinements.Equal(o.refinements) { + return false + } + if i.state != attr.ValueStateKnown { return true } @@ -117,7 +133,26 @@ func (i Int32Value) ToTerraformValue(ctx context.Context) (tftypes.Value, error) case attr.ValueStateNull: return tftypes.NewValue(tftypes.Number, nil), nil case attr.ValueStateUnknown: - return tftypes.NewValue(tftypes.Number, tftypes.UnknownValue), nil + if len(i.refinements) == 0 { + return tftypes.NewValue(tftypes.Number, tftypes.UnknownValue), nil + } + + unknownValRefinements := make(tfrefinement.Refinements, 0) + for _, refn := range i.refinements { + switch refnVal := refn.(type) { + case refinement.NotNull: + unknownValRefinements[tfrefinement.KeyNullness] = tfrefinement.NewNullness(false) + case refinement.Int32LowerBound: + lowerBound := new(big.Float).SetInt64(int64(refnVal.LowerBound())) + unknownValRefinements[tfrefinement.KeyNumberLowerBound] = tfrefinement.NewNumberLowerBound(lowerBound, refnVal.IsInclusive()) + case refinement.Int32UpperBound: + upperBound := new(big.Float).SetInt64(int64(refnVal.UpperBound())) + unknownValRefinements[tfrefinement.KeyNumberUpperBound] = tfrefinement.NewNumberUpperBound(upperBound, refnVal.IsInclusive()) + } + } + unknownVal := tftypes.NewValue(tftypes.Number, tftypes.UnknownValue) + + return unknownVal.Refine(unknownValRefinements), nil default: panic(fmt.Sprintf("unhandled Int32 state in ToTerraformValue: %s", i.state)) } @@ -143,7 +178,11 @@ func (i Int32Value) IsUnknown() bool { // and is intended for logging and error reporting. func (i Int32Value) String() string { if i.IsUnknown() { - return attr.UnknownValueString + if len(i.refinements) == 0 { + return attr.UnknownValueString + } + + return fmt.Sprintf("", i.refinements.String()) } if i.IsNull() { @@ -173,3 +212,146 @@ func (i Int32Value) ValueInt32Pointer() *int32 { func (i Int32Value) ToInt32Value(context.Context) (Int32Value, diag.Diagnostics) { return i, nil } + +// RefineAsNotNull will return an unknown Int32Value that includes a value refinement that: +// - Indicates the int32 value will not be null once it becomes known. +// +// If the provided Int32Value is null or known, then the Int32Value will be returned unchanged. +func (i Int32Value) RefineAsNotNull() Int32Value { + if !i.IsUnknown() { + return i + } + + newRefinements := make(refinement.Refinements, len(i.refinements)) + for i, refn := range i.refinements { + newRefinements[i] = refn + } + + newRefinements[refinement.KeyNotNull] = refinement.NewNotNull() + + newUnknownVal := NewInt32Unknown() + newUnknownVal.refinements = newRefinements + + return newUnknownVal +} + +// RefineWithLowerBound will return an unknown Int32Value that includes a value refinement that: +// - Indicates the int32 value will not be null once it becomes known. +// - Indicates the int32 value will not be less than the int32 provided (lowerBound) once it becomes known. +// +// If the provided Int32Value is null or known, then the Int32Value will be returned unchanged. +func (i Int32Value) RefineWithLowerBound(lowerBound int32, inclusive bool) Int32Value { + if !i.IsUnknown() { + return i + } + + newRefinements := make(refinement.Refinements, len(i.refinements)) + for i, refn := range i.refinements { + newRefinements[i] = refn + } + + newRefinements[refinement.KeyNotNull] = refinement.NewNotNull() + newRefinements[refinement.KeyNumberLowerBound] = refinement.NewInt32LowerBound(lowerBound, inclusive) + + newUnknownVal := NewInt32Unknown() + newUnknownVal.refinements = newRefinements + + return newUnknownVal +} + +// RefineWithUpperBound will return an unknown Int32Value that includes a value refinement that: +// - Indicates the int32 value will not be null once it becomes known. +// - Indicates the int32 value will not be greater than the int32 provided (upperBound) once it becomes known. +// +// If the provided Int32Value is null or known, then the Int32Value will be returned unchanged. +func (i Int32Value) RefineWithUpperBound(upperBound int32, inclusive bool) Int32Value { + if !i.IsUnknown() { + return i + } + + newRefinements := make(refinement.Refinements, len(i.refinements)) + for i, refn := range i.refinements { + newRefinements[i] = refn + } + + newRefinements[refinement.KeyNotNull] = refinement.NewNotNull() + newRefinements[refinement.KeyNumberUpperBound] = refinement.NewInt32UpperBound(upperBound, inclusive) + + newUnknownVal := NewInt32Unknown() + newUnknownVal.refinements = newRefinements + + return newUnknownVal +} + +// NotNullRefinement returns value refinement data and a boolean indicating if a NotNull refinement +// exists on the given Int32Value. If an Int32Value contains a NotNull refinement, this indicates that +// the int32 value is unknown, but the eventual known value will not be null. +// +// A NotNull value refinement can be added to an unknown value via the `RefineAsNotNull` method. +func (i Int32Value) NotNullRefinement() (*refinement.NotNull, bool) { + if !i.IsUnknown() { + return nil, false + } + + refn, ok := i.refinements[refinement.KeyNotNull] + if !ok { + return nil, false + } + + notNullRefn, ok := refn.(refinement.NotNull) + if !ok { + return nil, false + } + + return ¬NullRefn, true +} + +// LowerBoundRefinement returns value refinement data and a boolean indicating if a Int32LowerBound refinement +// exists on the given Int32Value. If an Int32Value contains a Int32LowerBound refinement, this indicates that +// the int32 value is unknown, but the eventual known value will not be less than the specified int32 value +// (either inclusive or exclusive) once it becomes known. The returned boolean should be checked before accessing +// refinement data. +// +// An Int32LowerBound value refinement can be added to an unknown value via the `RefineWithLowerBound` method. +func (i Int32Value) LowerBoundRefinement() (*refinement.Int32LowerBound, bool) { + if !i.IsUnknown() { + return nil, false + } + + refn, ok := i.refinements[refinement.KeyNumberLowerBound] + if !ok { + return nil, false + } + + lowerBoundRefn, ok := refn.(refinement.Int32LowerBound) + if !ok { + return nil, false + } + + return &lowerBoundRefn, true +} + +// UpperBoundRefinement returns value refinement data and a boolean indicating if a Int32UpperBound refinement +// exists on the given Int32Value. If an Int32Value contains a Int32UpperBound refinement, this indicates that +// the int32 value is unknown, but the eventual known value will not be greater than the specified int32 value +// (either inclusive or exclusive) once it becomes known. The returned boolean should be checked before accessing +// refinement data. +// +// A Int32UpperBound value refinement can be added to an unknown value via the `RefineWithUpperBound` method. +func (i Int32Value) UpperBoundRefinement() (*refinement.Int32UpperBound, bool) { + if !i.IsUnknown() { + return nil, false + } + + refn, ok := i.refinements[refinement.KeyNumberUpperBound] + if !ok { + return nil, false + } + + upperBoundRefn, ok := refn.(refinement.Int32UpperBound) + if !ok { + return nil, false + } + + return &upperBoundRefn, true +} diff --git a/types/basetypes/int32_value_test.go b/types/basetypes/int32_value_test.go index 15f505ca..130299f0 100644 --- a/types/basetypes/int32_value_test.go +++ b/types/basetypes/int32_value_test.go @@ -11,8 +11,10 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform-plugin-go/tftypes" + tfrefinement "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types/refinement" ) func TestInt32ValueToTerraformValue(t *testing.T) { @@ -31,6 +33,34 @@ func TestInt32ValueToTerraformValue(t *testing.T) { input: NewInt32Unknown(), expectation: tftypes.NewValue(tftypes.Number, tftypes.UnknownValue), }, + "unknown-with-notnull-refinement": { + input: NewInt32Unknown().RefineAsNotNull(), + expectation: tftypes.NewValue(tftypes.Number, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + }), + }, + "unknown-with-lower-bound-refinement": { + input: NewInt32Unknown().RefineWithLowerBound(10, true), + expectation: tftypes.NewValue(tftypes.Number, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + tfrefinement.KeyNumberLowerBound: tfrefinement.NewNumberLowerBound(big.NewFloat(10), true), + }), + }, + "unknown-with-upper-bound-refinement": { + input: NewInt32Unknown().RefineWithUpperBound(100, false), + expectation: tftypes.NewValue(tftypes.Number, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + tfrefinement.KeyNumberUpperBound: tfrefinement.NewNumberUpperBound(big.NewFloat(100), false), + }), + }, + "unknown-with-both-bound-refinements": { + input: NewInt32Unknown().RefineWithLowerBound(10, true).RefineWithUpperBound(100, false), + expectation: tftypes.NewValue(tftypes.Number, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + tfrefinement.KeyNumberLowerBound: tfrefinement.NewNumberLowerBound(big.NewFloat(10), true), + tfrefinement.KeyNumberUpperBound: tfrefinement.NewNumberUpperBound(big.NewFloat(100), false), + }), + }, "null": { input: NewInt32Null(), expectation: tftypes.NewValue(tftypes.Number, nil), @@ -93,6 +123,71 @@ func TestInt32ValueEqual(t *testing.T) { candidate: NewInt32Unknown(), expectation: true, }, + "unknown-unknown-with-notnull-refinement": { + input: NewInt32Unknown(), + candidate: NewInt32Unknown().RefineAsNotNull(), + expectation: false, + }, + "unknown-unknown-with-lowerbound-refinement": { + input: NewInt32Unknown(), + candidate: NewInt32Unknown().RefineWithLowerBound(10, true), + expectation: false, + }, + "unknown-unknown-with-upperbound-refinement": { + input: NewInt32Unknown(), + candidate: NewInt32Unknown().RefineWithUpperBound(100, false), + expectation: false, + }, + "unknowns-with-matching-notnull-refinements": { + input: NewInt32Unknown().RefineAsNotNull(), + candidate: NewInt32Unknown().RefineAsNotNull(), + expectation: true, + }, + "unknowns-with-matching-lowerbound-refinements": { + input: NewInt32Unknown().RefineWithLowerBound(10, true), + candidate: NewInt32Unknown().RefineWithLowerBound(10, true), + expectation: true, + }, + "unknowns-with-different-lowerbound-refinements": { + input: NewInt32Unknown().RefineWithLowerBound(10, true), + candidate: NewInt32Unknown().RefineWithLowerBound(11, true), + expectation: false, + }, + "unknowns-with-different-lowerbound-refinements-inclusive": { + input: NewInt32Unknown().RefineWithLowerBound(10, true), + candidate: NewInt32Unknown().RefineWithLowerBound(10, false), + expectation: false, + }, + "unknowns-with-matching-upperbound-refinements": { + input: NewInt32Unknown().RefineWithUpperBound(100, true), + candidate: NewInt32Unknown().RefineWithUpperBound(100, true), + expectation: true, + }, + "unknowns-with-different-upperbound-refinements": { + input: NewInt32Unknown().RefineWithUpperBound(100, true), + candidate: NewInt32Unknown().RefineWithUpperBound(101, true), + expectation: false, + }, + "unknowns-with-different-upperbound-refinements-inclusive": { + input: NewInt32Unknown().RefineWithUpperBound(100, true), + candidate: NewInt32Unknown().RefineWithUpperBound(100, false), + expectation: false, + }, + "unknowns-with-matching-both-bound-refinements": { + input: NewInt32Unknown().RefineWithLowerBound(10, true).RefineWithUpperBound(100, true), + candidate: NewInt32Unknown().RefineWithLowerBound(10, true).RefineWithUpperBound(100, true), + expectation: true, + }, + "unknowns-with-different-both-bound-refinements": { + input: NewInt32Unknown().RefineWithLowerBound(10, true).RefineWithUpperBound(100, true), + candidate: NewInt32Unknown().RefineWithLowerBound(10, true).RefineWithUpperBound(101, true), + expectation: false, + }, + "unknowns-with-different-both-bound-refinements-inclusive": { + input: NewInt32Unknown().RefineWithLowerBound(10, true).RefineWithUpperBound(100, true), + candidate: NewInt32Unknown().RefineWithLowerBound(10, true).RefineWithUpperBound(100, false), + expectation: false, + }, "unknown-null": { input: NewInt32Unknown(), candidate: NewInt32Null(), @@ -227,6 +322,22 @@ func TestInt32ValueString(t *testing.T) { input: NewInt32Unknown(), expectation: "", }, + "unknown-with-notnull-refinement": { + input: NewInt32Unknown().RefineAsNotNull(), + expectation: "", + }, + "unknown-with-lowerbound-refinement": { + input: NewInt32Unknown().RefineWithLowerBound(10, true), + expectation: ``, + }, + "unknown-with-upperbound-refinement": { + input: NewInt32Unknown().RefineWithUpperBound(100, false), + expectation: ``, + }, + "unknown-with-both-bound-refinements": { + input: NewInt32Unknown().RefineWithLowerBound(10, true).RefineWithUpperBound(100, false), + expectation: ``, + }, "null": { input: NewInt32Null(), expectation: "", @@ -353,3 +464,162 @@ func TestNewInt32PointerValue(t *testing.T) { }) } } + +func TestInt32Value_NotNullRefinement(t *testing.T) { + t.Parallel() + + type testCase struct { + input Int32Value + expectedRefnVal refinement.Refinement + expectedFound bool + } + tests := map[string]testCase{ + "known-ignored": { + input: NewInt32Value(100).RefineAsNotNull(), + expectedFound: false, + }, + "null-ignored": { + input: NewInt32Null().RefineAsNotNull(), + expectedFound: false, + }, + "unknown-no-refinement": { + input: NewInt32Unknown(), + expectedFound: false, + }, + "unknown-with-notnull-refinement": { + input: NewInt32Unknown().RefineAsNotNull(), + expectedRefnVal: refinement.NewNotNull(), + expectedFound: true, + }, + } + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, found := test.input.NotNullRefinement() + if found != test.expectedFound { + t.Fatalf("Expected refinement exists to be: %t, got: %t", test.expectedFound, found) + } + + if got == nil && test.expectedRefnVal == nil { + // Success! + return + } + + if got == nil && test.expectedRefnVal != nil { + t.Fatalf("Expected refinement data: <%+v>, got: nil", test.expectedRefnVal) + } + + if diff := cmp.Diff(*got, test.expectedRefnVal); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestInt32Value_LowerBoundRefinement(t *testing.T) { + t.Parallel() + + type testCase struct { + input Int32Value + expectedRefnVal refinement.Refinement + expectedFound bool + } + tests := map[string]testCase{ + "known-ignored": { + input: NewInt32Value(100).RefineWithLowerBound(10, true), + expectedFound: false, + }, + "null-ignored": { + input: NewInt32Null().RefineWithLowerBound(10, true), + expectedFound: false, + }, + "unknown-no-refinement": { + input: NewInt32Unknown(), + expectedFound: false, + }, + "unknown-with-lowerbound-refinement": { + input: NewInt32Unknown().RefineWithLowerBound(10, true), + expectedRefnVal: refinement.NewInt32LowerBound(10, true), + expectedFound: true, + }, + } + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, found := test.input.LowerBoundRefinement() + if found != test.expectedFound { + t.Fatalf("Expected refinement exists to be: %t, got: %t", test.expectedFound, found) + } + + if got == nil && test.expectedRefnVal == nil { + // Success! + return + } + + if got == nil && test.expectedRefnVal != nil { + t.Fatalf("Expected refinement data: <%+v>, got: nil", test.expectedRefnVal) + } + + if diff := cmp.Diff(*got, test.expectedRefnVal); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestInt32Value_UpperBoundRefinement(t *testing.T) { + t.Parallel() + + type testCase struct { + input Int32Value + expectedRefnVal refinement.Refinement + expectedFound bool + } + tests := map[string]testCase{ + "known-ignored": { + input: NewInt32Value(100).RefineWithUpperBound(10, true), + expectedFound: false, + }, + "null-ignored": { + input: NewInt32Null().RefineWithUpperBound(10, true), + expectedFound: false, + }, + "unknown-no-refinement": { + input: NewInt32Unknown(), + expectedFound: false, + }, + "unknown-with-upperbound-refinement": { + input: NewInt32Unknown().RefineWithUpperBound(10, true), + expectedRefnVal: refinement.NewInt32UpperBound(10, true), + expectedFound: true, + }, + } + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, found := test.input.UpperBoundRefinement() + if found != test.expectedFound { + t.Fatalf("Expected refinement exists to be: %t, got: %t", test.expectedFound, found) + } + + if got == nil && test.expectedRefnVal == nil { + // Success! + return + } + + if got == nil && test.expectedRefnVal != nil { + t.Fatalf("Expected refinement data: <%+v>, got: nil", test.expectedRefnVal) + } + + if diff := cmp.Diff(*got, test.expectedRefnVal); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/types/basetypes/int64_type.go b/types/basetypes/int64_type.go index 93fa1b9e..c6ee0af7 100644 --- a/types/basetypes/int64_type.go +++ b/types/basetypes/int64_type.go @@ -9,6 +9,7 @@ import ( "math/big" "github.com/hashicorp/terraform-plugin-go/tftypes" + tfrefinement "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/attr/xattr" @@ -124,7 +125,43 @@ func (t Int64Type) ValueFromInt64(_ context.Context, v Int64Value) (Int64Valuabl // consume the data with. func (t Int64Type) ValueFromTerraform(ctx context.Context, in tftypes.Value) (attr.Value, error) { if !in.IsKnown() { - return NewInt64Unknown(), nil + unknownVal := NewInt64Unknown() + refinements := in.Refinements() + + if len(refinements) == 0 { + return unknownVal, nil + } + + for _, refn := range refinements { + switch refnVal := refn.(type) { + case tfrefinement.Nullness: + if !refnVal.Nullness() { + unknownVal = unknownVal.RefineAsNotNull() + } else { + // This scenario shouldn't occur, as Terraform should have already collapsed an + // unknown value with a definitely null refinement into a known null value. However, + // the protocol encoding does support this refinement value, so we'll also just collapse + // it into a known null value here. + return NewInt64Null(), nil + } + case tfrefinement.NumberLowerBound: + // TODO: Is it possible for Terraform to create this refinement? Should we chop off the decimal point? + boundVal, err := tryBigFloatToInt64(refnVal.LowerBound()) + if err != nil { + return nil, fmt.Errorf("error parsing lower bound refinement: %w", err) + } + unknownVal = unknownVal.RefineWithLowerBound(boundVal, refnVal.IsInclusive()) + case tfrefinement.NumberUpperBound: + // TODO: Is it possible for Terraform to create this refinement? Should we chop off the decimal point? + boundVal, err := tryBigFloatToInt64(refnVal.UpperBound()) + if err != nil { + return nil, fmt.Errorf("error parsing upper bound refinement: %w", err) + } + unknownVal = unknownVal.RefineWithUpperBound(boundVal, refnVal.IsInclusive()) + } + } + + return unknownVal, nil } if in.IsNull() { @@ -138,14 +175,9 @@ func (t Int64Type) ValueFromTerraform(ctx context.Context, in tftypes.Value) (at return nil, err } - if !bigF.IsInt() { - return nil, fmt.Errorf("Value %s is not an integer.", bigF) - } - - i, accuracy := bigF.Int64() - - if accuracy != 0 { - return nil, fmt.Errorf("Value %s cannot be represented as a 64-bit integer.", bigF) + i, err := tryBigFloatToInt64(bigF) + if err != nil { + return nil, err } return NewInt64Value(i), nil @@ -156,3 +188,17 @@ func (t Int64Type) ValueType(_ context.Context) attr.Value { // This Value does not need to be valid. return Int64Value{} } + +func tryBigFloatToInt64(bigF *big.Float) (int64, error) { + if !bigF.IsInt() { + return 0, fmt.Errorf("Value %s is not an integer.", bigF) + } + + i, accuracy := bigF.Int64() + + if accuracy != 0 { + return 0, fmt.Errorf("Value %s cannot be represented as a 64-bit integer.", bigF) + } + + return i, nil +} diff --git a/types/basetypes/int64_type_test.go b/types/basetypes/int64_type_test.go index f9986605..5decd111 100644 --- a/types/basetypes/int64_type_test.go +++ b/types/basetypes/int64_type_test.go @@ -5,10 +5,12 @@ package basetypes import ( "context" + "math/big" "testing" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-go/tftypes" + tfrefinement "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" ) func TestInt64TypeValueFromTerraform(t *testing.T) { @@ -28,6 +30,34 @@ func TestInt64TypeValueFromTerraform(t *testing.T) { input: tftypes.NewValue(tftypes.Number, tftypes.UnknownValue), expectation: NewInt64Unknown(), }, + "unknown-with-notnull-refinement": { + input: tftypes.NewValue(tftypes.Number, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + }), + expectation: NewInt64Unknown().RefineAsNotNull(), + }, + "unknown-with-lowerbound-refinement": { + input: tftypes.NewValue(tftypes.Number, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + tfrefinement.KeyNumberLowerBound: tfrefinement.NewNumberLowerBound(big.NewFloat(10), true), + }), + expectation: NewInt64Unknown().RefineWithLowerBound(10, true), + }, + "unknown-with-upperbound-refinement": { + input: tftypes.NewValue(tftypes.Number, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + tfrefinement.KeyNumberUpperBound: tfrefinement.NewNumberUpperBound(big.NewFloat(100), false), + }), + expectation: NewInt64Unknown().RefineWithUpperBound(100, false), + }, + "unknown-with-both-bound-refinements": { + input: tftypes.NewValue(tftypes.Number, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + tfrefinement.KeyNumberLowerBound: tfrefinement.NewNumberLowerBound(big.NewFloat(10), true), + tfrefinement.KeyNumberUpperBound: tfrefinement.NewNumberUpperBound(big.NewFloat(100), false), + }), + expectation: NewInt64Unknown().RefineWithLowerBound(10, true).RefineWithUpperBound(100, false), + }, "null": { input: tftypes.NewValue(tftypes.Number, nil), expectation: NewInt64Null(), @@ -57,7 +87,7 @@ func TestInt64TypeValueFromTerraform(t *testing.T) { // expectations, we're good return } - if err == nil && test.expectedErr != "" { + if test.expectedErr != "" { t.Errorf("Expected error to be %q, didn't get an error", test.expectedErr) return } @@ -73,3 +103,23 @@ func TestInt64TypeValueFromTerraform(t *testing.T) { }) } } + +func TestInt64TypeValueFromTerraform_RefinementNullCollapse(t *testing.T) { + t.Parallel() + + // This shouldn't happen, but this test ensures that if we receive this kind of refinement, that we will + // convert it to a known null value. + input := tftypes.NewValue(tftypes.Number, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(true), + }) + expectation := NewInt64Null() + + got, err := Int64Type{}.ValueFromTerraform(context.Background(), input) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + if !got.Equal(expectation) { + t.Errorf("Expected %+v, got %+v", expectation, got) + } +} diff --git a/types/basetypes/int64_value.go b/types/basetypes/int64_value.go index bf8b3bd5..a5e7814a 100644 --- a/types/basetypes/int64_value.go +++ b/types/basetypes/int64_value.go @@ -6,15 +6,19 @@ package basetypes import ( "context" "fmt" + "math/big" "github.com/hashicorp/terraform-plugin-go/tftypes" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types/refinement" + tfrefinement "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" ) var ( - _ Int64Valuable = Int64Value{} + _ Int64Valuable = Int64Value{} + _ attr.ValueWithNotNullRefinement = Int64Value{} ) // Int64Valuable extends attr.Value for int64 value types. @@ -84,6 +88,10 @@ type Int64Value struct { // value contains the known value, if not null or unknown. value int64 + + // refinements represents the unknown value refinement data associated with this Value. + // This field is only populated for unknown values. + refinements refinement.Refinements } // Equal returns true if `other` is an Int64 and has the same value as `i`. @@ -98,6 +106,14 @@ func (i Int64Value) Equal(other attr.Value) bool { return false } + if len(i.refinements) != len(o.refinements) { + return false + } + + if len(i.refinements) > 0 && !i.refinements.Equal(o.refinements) { + return false + } + if i.state != attr.ValueStateKnown { return true } @@ -117,7 +133,26 @@ func (i Int64Value) ToTerraformValue(ctx context.Context) (tftypes.Value, error) case attr.ValueStateNull: return tftypes.NewValue(tftypes.Number, nil), nil case attr.ValueStateUnknown: - return tftypes.NewValue(tftypes.Number, tftypes.UnknownValue), nil + if len(i.refinements) == 0 { + return tftypes.NewValue(tftypes.Number, tftypes.UnknownValue), nil + } + + unknownValRefinements := make(tfrefinement.Refinements, 0) + for _, refn := range i.refinements { + switch refnVal := refn.(type) { + case refinement.NotNull: + unknownValRefinements[tfrefinement.KeyNullness] = tfrefinement.NewNullness(false) + case refinement.Int64LowerBound: + lowerBound := new(big.Float).SetInt64(refnVal.LowerBound()) + unknownValRefinements[tfrefinement.KeyNumberLowerBound] = tfrefinement.NewNumberLowerBound(lowerBound, refnVal.IsInclusive()) + case refinement.Int64UpperBound: + upperBound := new(big.Float).SetInt64(refnVal.UpperBound()) + unknownValRefinements[tfrefinement.KeyNumberUpperBound] = tfrefinement.NewNumberUpperBound(upperBound, refnVal.IsInclusive()) + } + } + unknownVal := tftypes.NewValue(tftypes.Number, tftypes.UnknownValue) + + return unknownVal.Refine(unknownValRefinements), nil default: panic(fmt.Sprintf("unhandled Int64 state in ToTerraformValue: %s", i.state)) } @@ -143,7 +178,11 @@ func (i Int64Value) IsUnknown() bool { // and is intended for logging and error reporting. func (i Int64Value) String() string { if i.IsUnknown() { - return attr.UnknownValueString + if len(i.refinements) == 0 { + return attr.UnknownValueString + } + + return fmt.Sprintf("", i.refinements.String()) } if i.IsNull() { @@ -173,3 +212,146 @@ func (i Int64Value) ValueInt64Pointer() *int64 { func (i Int64Value) ToInt64Value(context.Context) (Int64Value, diag.Diagnostics) { return i, nil } + +// RefineAsNotNull will return an unknown Int64Value that includes a value refinement that: +// - Indicates the int64 value will not be null once it becomes known. +// +// If the provided Int64Value is null or known, then the Int64Value will be returned unchanged. +func (i Int64Value) RefineAsNotNull() Int64Value { + if !i.IsUnknown() { + return i + } + + newRefinements := make(refinement.Refinements, len(i.refinements)) + for i, refn := range i.refinements { + newRefinements[i] = refn + } + + newRefinements[refinement.KeyNotNull] = refinement.NewNotNull() + + newUnknownVal := NewInt64Unknown() + newUnknownVal.refinements = newRefinements + + return newUnknownVal +} + +// RefineWithLowerBound will return an unknown Int64Value that includes a value refinement that: +// - Indicates the int64 value will not be null once it becomes known. +// - Indicates the int64 value will not be less than the int64 provided (lowerBound) once it becomes known. +// +// If the provided Int64Value is null or known, then the Int64Value will be returned unchanged. +func (i Int64Value) RefineWithLowerBound(lowerBound int64, inclusive bool) Int64Value { + if !i.IsUnknown() { + return i + } + + newRefinements := make(refinement.Refinements, len(i.refinements)) + for i, refn := range i.refinements { + newRefinements[i] = refn + } + + newRefinements[refinement.KeyNotNull] = refinement.NewNotNull() + newRefinements[refinement.KeyNumberLowerBound] = refinement.NewInt64LowerBound(lowerBound, inclusive) + + newUnknownVal := NewInt64Unknown() + newUnknownVal.refinements = newRefinements + + return newUnknownVal +} + +// RefineWithUpperBound will return an unknown Int64Value that includes a value refinement that: +// - Indicates the int64 value will not be null once it becomes known. +// - Indicates the int64 value will not be greater than the int64 provided (upperBound) once it becomes known. +// +// If the provided Int64Value is null or known, then the Int64Value will be returned unchanged. +func (i Int64Value) RefineWithUpperBound(upperBound int64, inclusive bool) Int64Value { + if !i.IsUnknown() { + return i + } + + newRefinements := make(refinement.Refinements, len(i.refinements)) + for i, refn := range i.refinements { + newRefinements[i] = refn + } + + newRefinements[refinement.KeyNotNull] = refinement.NewNotNull() + newRefinements[refinement.KeyNumberUpperBound] = refinement.NewInt64UpperBound(upperBound, inclusive) + + newUnknownVal := NewInt64Unknown() + newUnknownVal.refinements = newRefinements + + return newUnknownVal +} + +// NotNullRefinement returns value refinement data and a boolean indicating if a NotNull refinement +// exists on the given Int64Value. If an Int64Value contains a NotNull refinement, this indicates that +// the int64 value is unknown, but the eventual known value will not be null. +// +// A NotNull value refinement can be added to an unknown value via the `RefineAsNotNull` method. +func (i Int64Value) NotNullRefinement() (*refinement.NotNull, bool) { + if !i.IsUnknown() { + return nil, false + } + + refn, ok := i.refinements[refinement.KeyNotNull] + if !ok { + return nil, false + } + + notNullRefn, ok := refn.(refinement.NotNull) + if !ok { + return nil, false + } + + return ¬NullRefn, true +} + +// LowerBoundRefinement returns value refinement data and a boolean indicating if a Int64LowerBound refinement +// exists on the given Int64Value. If an Int64Value contains a Int64LowerBound refinement, this indicates that +// the int64 value is unknown, but the eventual known value will not be less than the specified int64 value +// (either inclusive or exclusive) once it becomes known. The returned boolean should be checked before accessing +// refinement data. +// +// An Int64LowerBound value refinement can be added to an unknown value via the `RefineWithLowerBound` method. +func (i Int64Value) LowerBoundRefinement() (*refinement.Int64LowerBound, bool) { + if !i.IsUnknown() { + return nil, false + } + + refn, ok := i.refinements[refinement.KeyNumberLowerBound] + if !ok { + return nil, false + } + + lowerBoundRefn, ok := refn.(refinement.Int64LowerBound) + if !ok { + return nil, false + } + + return &lowerBoundRefn, true +} + +// UpperBoundRefinement returns value refinement data and a boolean indicating if a Int64UpperBound refinement +// exists on the given Int64Value. If an Int64Value contains a Int64UpperBound refinement, this indicates that +// the int64 value is unknown, but the eventual known value will not be greater than the specified int64 value +// (either inclusive or exclusive) once it becomes known. The returned boolean should be checked before accessing +// refinement data. +// +// A Int64UpperBound value refinement can be added to an unknown value via the `RefineWithUpperBound` method. +func (i Int64Value) UpperBoundRefinement() (*refinement.Int64UpperBound, bool) { + if !i.IsUnknown() { + return nil, false + } + + refn, ok := i.refinements[refinement.KeyNumberUpperBound] + if !ok { + return nil, false + } + + upperBoundRefn, ok := refn.(refinement.Int64UpperBound) + if !ok { + return nil, false + } + + return &upperBoundRefn, true +} diff --git a/types/basetypes/int64_value_test.go b/types/basetypes/int64_value_test.go index 8afbcc18..cb2f36c2 100644 --- a/types/basetypes/int64_value_test.go +++ b/types/basetypes/int64_value_test.go @@ -11,7 +11,9 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types/refinement" "github.com/hashicorp/terraform-plugin-go/tftypes" + tfrefinement "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" ) func TestInt64ValueToTerraformValue(t *testing.T) { @@ -30,6 +32,34 @@ func TestInt64ValueToTerraformValue(t *testing.T) { input: NewInt64Unknown(), expectation: tftypes.NewValue(tftypes.Number, tftypes.UnknownValue), }, + "unknown-with-notnull-refinement": { + input: NewInt64Unknown().RefineAsNotNull(), + expectation: tftypes.NewValue(tftypes.Number, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + }), + }, + "unknown-with-lower-bound-refinement": { + input: NewInt64Unknown().RefineWithLowerBound(10, true), + expectation: tftypes.NewValue(tftypes.Number, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + tfrefinement.KeyNumberLowerBound: tfrefinement.NewNumberLowerBound(big.NewFloat(10), true), + }), + }, + "unknown-with-upper-bound-refinement": { + input: NewInt64Unknown().RefineWithUpperBound(100, false), + expectation: tftypes.NewValue(tftypes.Number, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + tfrefinement.KeyNumberUpperBound: tfrefinement.NewNumberUpperBound(big.NewFloat(100), false), + }), + }, + "unknown-with-both-bound-refinements": { + input: NewInt64Unknown().RefineWithLowerBound(10, true).RefineWithUpperBound(100, false), + expectation: tftypes.NewValue(tftypes.Number, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + tfrefinement.KeyNumberLowerBound: tfrefinement.NewNumberLowerBound(big.NewFloat(10), true), + tfrefinement.KeyNumberUpperBound: tfrefinement.NewNumberUpperBound(big.NewFloat(100), false), + }), + }, "null": { input: NewInt64Null(), expectation: tftypes.NewValue(tftypes.Number, nil), @@ -92,6 +122,71 @@ func TestInt64ValueEqual(t *testing.T) { candidate: NewInt64Unknown(), expectation: true, }, + "unknown-unknown-with-notnull-refinement": { + input: NewInt64Unknown(), + candidate: NewInt64Unknown().RefineAsNotNull(), + expectation: false, + }, + "unknown-unknown-with-lowerbound-refinement": { + input: NewInt64Unknown(), + candidate: NewInt64Unknown().RefineWithLowerBound(10, true), + expectation: false, + }, + "unknown-unknown-with-upperbound-refinement": { + input: NewInt64Unknown(), + candidate: NewInt64Unknown().RefineWithUpperBound(100, false), + expectation: false, + }, + "unknowns-with-matching-notnull-refinements": { + input: NewInt64Unknown().RefineAsNotNull(), + candidate: NewInt64Unknown().RefineAsNotNull(), + expectation: true, + }, + "unknowns-with-matching-lowerbound-refinements": { + input: NewInt64Unknown().RefineWithLowerBound(10, true), + candidate: NewInt64Unknown().RefineWithLowerBound(10, true), + expectation: true, + }, + "unknowns-with-different-lowerbound-refinements": { + input: NewInt64Unknown().RefineWithLowerBound(10, true), + candidate: NewInt64Unknown().RefineWithLowerBound(11, true), + expectation: false, + }, + "unknowns-with-different-lowerbound-refinements-inclusive": { + input: NewInt64Unknown().RefineWithLowerBound(10, true), + candidate: NewInt64Unknown().RefineWithLowerBound(10, false), + expectation: false, + }, + "unknowns-with-matching-upperbound-refinements": { + input: NewInt64Unknown().RefineWithUpperBound(100, true), + candidate: NewInt64Unknown().RefineWithUpperBound(100, true), + expectation: true, + }, + "unknowns-with-different-upperbound-refinements": { + input: NewInt64Unknown().RefineWithUpperBound(100, true), + candidate: NewInt64Unknown().RefineWithUpperBound(101, true), + expectation: false, + }, + "unknowns-with-different-upperbound-refinements-inclusive": { + input: NewInt64Unknown().RefineWithUpperBound(100, true), + candidate: NewInt64Unknown().RefineWithUpperBound(100, false), + expectation: false, + }, + "unknowns-with-matching-both-bound-refinements": { + input: NewInt64Unknown().RefineWithLowerBound(10, true).RefineWithUpperBound(100, true), + candidate: NewInt64Unknown().RefineWithLowerBound(10, true).RefineWithUpperBound(100, true), + expectation: true, + }, + "unknowns-with-different-both-bound-refinements": { + input: NewInt64Unknown().RefineWithLowerBound(10, true).RefineWithUpperBound(100, true), + candidate: NewInt64Unknown().RefineWithLowerBound(10, true).RefineWithUpperBound(101, true), + expectation: false, + }, + "unknowns-with-different-both-bound-refinements-inclusive": { + input: NewInt64Unknown().RefineWithLowerBound(10, true).RefineWithUpperBound(100, true), + candidate: NewInt64Unknown().RefineWithLowerBound(10, true).RefineWithUpperBound(100, false), + expectation: false, + }, "unknown-null": { input: NewInt64Unknown(), candidate: NewInt64Null(), @@ -226,6 +321,22 @@ func TestInt64ValueString(t *testing.T) { input: NewInt64Unknown(), expectation: "", }, + "unknown-with-notnull-refinement": { + input: NewInt64Unknown().RefineAsNotNull(), + expectation: "", + }, + "unknown-with-lowerbound-refinement": { + input: NewInt64Unknown().RefineWithLowerBound(10, true), + expectation: ``, + }, + "unknown-with-upperbound-refinement": { + input: NewInt64Unknown().RefineWithUpperBound(100, false), + expectation: ``, + }, + "unknown-with-both-bound-refinements": { + input: NewInt64Unknown().RefineWithLowerBound(10, true).RefineWithUpperBound(100, false), + expectation: ``, + }, "null": { input: NewInt64Null(), expectation: "", @@ -352,3 +463,162 @@ func TestNewInt64PointerValue(t *testing.T) { }) } } + +func TestInt64Value_NotNullRefinement(t *testing.T) { + t.Parallel() + + type testCase struct { + input Int64Value + expectedRefnVal refinement.Refinement + expectedFound bool + } + tests := map[string]testCase{ + "known-ignored": { + input: NewInt64Value(100).RefineAsNotNull(), + expectedFound: false, + }, + "null-ignored": { + input: NewInt64Null().RefineAsNotNull(), + expectedFound: false, + }, + "unknown-no-refinement": { + input: NewInt64Unknown(), + expectedFound: false, + }, + "unknown-with-notnull-refinement": { + input: NewInt64Unknown().RefineAsNotNull(), + expectedRefnVal: refinement.NewNotNull(), + expectedFound: true, + }, + } + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, found := test.input.NotNullRefinement() + if found != test.expectedFound { + t.Fatalf("Expected refinement exists to be: %t, got: %t", test.expectedFound, found) + } + + if got == nil && test.expectedRefnVal == nil { + // Success! + return + } + + if got == nil && test.expectedRefnVal != nil { + t.Fatalf("Expected refinement data: <%+v>, got: nil", test.expectedRefnVal) + } + + if diff := cmp.Diff(*got, test.expectedRefnVal); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestInt64Value_LowerBoundRefinement(t *testing.T) { + t.Parallel() + + type testCase struct { + input Int64Value + expectedRefnVal refinement.Refinement + expectedFound bool + } + tests := map[string]testCase{ + "known-ignored": { + input: NewInt64Value(100).RefineWithLowerBound(10, true), + expectedFound: false, + }, + "null-ignored": { + input: NewInt64Null().RefineWithLowerBound(10, true), + expectedFound: false, + }, + "unknown-no-refinement": { + input: NewInt64Unknown(), + expectedFound: false, + }, + "unknown-with-lowerbound-refinement": { + input: NewInt64Unknown().RefineWithLowerBound(10, true), + expectedRefnVal: refinement.NewInt64LowerBound(10, true), + expectedFound: true, + }, + } + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, found := test.input.LowerBoundRefinement() + if found != test.expectedFound { + t.Fatalf("Expected refinement exists to be: %t, got: %t", test.expectedFound, found) + } + + if got == nil && test.expectedRefnVal == nil { + // Success! + return + } + + if got == nil && test.expectedRefnVal != nil { + t.Fatalf("Expected refinement data: <%+v>, got: nil", test.expectedRefnVal) + } + + if diff := cmp.Diff(*got, test.expectedRefnVal); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestInt64Value_UpperBoundRefinement(t *testing.T) { + t.Parallel() + + type testCase struct { + input Int64Value + expectedRefnVal refinement.Refinement + expectedFound bool + } + tests := map[string]testCase{ + "known-ignored": { + input: NewInt64Value(100).RefineWithUpperBound(10, true), + expectedFound: false, + }, + "null-ignored": { + input: NewInt64Null().RefineWithUpperBound(10, true), + expectedFound: false, + }, + "unknown-no-refinement": { + input: NewInt64Unknown(), + expectedFound: false, + }, + "unknown-with-upperbound-refinement": { + input: NewInt64Unknown().RefineWithUpperBound(10, true), + expectedRefnVal: refinement.NewInt64UpperBound(10, true), + expectedFound: true, + }, + } + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, found := test.input.UpperBoundRefinement() + if found != test.expectedFound { + t.Fatalf("Expected refinement exists to be: %t, got: %t", test.expectedFound, found) + } + + if got == nil && test.expectedRefnVal == nil { + // Success! + return + } + + if got == nil && test.expectedRefnVal != nil { + t.Fatalf("Expected refinement data: <%+v>, got: nil", test.expectedRefnVal) + } + + if diff := cmp.Diff(*got, test.expectedRefnVal); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/types/basetypes/list_type.go b/types/basetypes/list_type.go index ef1b8a13..b5dac797 100644 --- a/types/basetypes/list_type.go +++ b/types/basetypes/list_type.go @@ -8,6 +8,7 @@ import ( "fmt" "github.com/hashicorp/terraform-plugin-go/tftypes" + tfrefinement "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/attr/xattr" @@ -84,7 +85,33 @@ func (l ListType) ValueFromTerraform(ctx context.Context, in tftypes.Value) (att return nil, fmt.Errorf("can't use %s as value of List with ElementType %T, can only use %s values", in.String(), l.ElementType(), l.ElementType().TerraformType(ctx).String()) } if !in.IsKnown() { - return NewListUnknown(l.ElementType()), nil + unknownVal := NewListUnknown(l.ElementType()) + refinements := in.Refinements() + + if len(refinements) == 0 { + return unknownVal, nil + } + + for _, refn := range refinements { + switch refnVal := refn.(type) { + case tfrefinement.Nullness: + if !refnVal.Nullness() { + unknownVal = unknownVal.RefineAsNotNull() + } else { + // This scenario shouldn't occur, as Terraform should have already collapsed an + // unknown value with a definitely null refinement into a known null value. However, + // the protocol encoding does support this refinement value, so we'll also just collapse + // it into a known null value here. + return NewListNull(l.ElementType()), nil + } + case tfrefinement.CollectionLengthLowerBound: + unknownVal = unknownVal.RefineWithLengthLowerBound(refnVal.LowerBound()) + case tfrefinement.CollectionLengthUpperBound: + unknownVal = unknownVal.RefineWithLengthUpperBound(refnVal.UpperBound()) + } + } + + return unknownVal, nil } if in.IsNull() { return NewListNull(l.ElementType()), nil diff --git a/types/basetypes/list_type_test.go b/types/basetypes/list_type_test.go index 6939a8f4..5685ef64 100644 --- a/types/basetypes/list_type_test.go +++ b/types/basetypes/list_type_test.go @@ -10,6 +10,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-go/tftypes" + tfrefinement "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" ) func TestListTypeElementType(t *testing.T) { @@ -145,6 +146,46 @@ func TestListTypeValueFromTerraform(t *testing.T) { }, tftypes.UnknownValue), expected: NewListUnknown(StringType{}), }, + "unknown-with-notnull-refinement": { + receiver: ListType{ + ElemType: StringType{}, + }, + input: tftypes.NewValue(tftypes.List{ElementType: tftypes.String}, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + }), + expected: NewListUnknown(StringType{}).RefineAsNotNull(), + }, + "unknown-with-length-lowerbound-refinement": { + receiver: ListType{ + ElemType: StringType{}, + }, + input: tftypes.NewValue(tftypes.List{ElementType: tftypes.String}, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + tfrefinement.KeyCollectionLengthLowerBound: tfrefinement.NewCollectionLengthLowerBound(5), + }), + expected: NewListUnknown(StringType{}).RefineWithLengthLowerBound(5), + }, + "unknown-with-length-upperbound-refinement": { + receiver: ListType{ + ElemType: StringType{}, + }, + input: tftypes.NewValue(tftypes.List{ElementType: tftypes.String}, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + tfrefinement.KeyCollectionLengthUpperBound: tfrefinement.NewCollectionLengthUpperBound(10), + }), + expected: NewListUnknown(StringType{}).RefineWithLengthUpperBound(10), + }, + "unknown-with-both-length-bound-refinements": { + receiver: ListType{ + ElemType: StringType{}, + }, + input: tftypes.NewValue(tftypes.List{ElementType: tftypes.String}, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + tfrefinement.KeyCollectionLengthLowerBound: tfrefinement.NewCollectionLengthLowerBound(5), + tfrefinement.KeyCollectionLengthUpperBound: tfrefinement.NewCollectionLengthUpperBound(10), + }), + expected: NewListUnknown(StringType{}).RefineWithLengthLowerBound(5).RefineWithLengthUpperBound(10), + }, "partially-unknown-list": { receiver: ListType{ ElemType: StringType{}, @@ -342,3 +383,23 @@ func TestListTypeString(t *testing.T) { }) } } + +func TestListTypeValueFromTerraform_RefinementNullCollapse(t *testing.T) { + t.Parallel() + + // This shouldn't happen, but this test ensures that if we receive this kind of refinement, that we will + // convert it to a known null value. + input := tftypes.NewValue(tftypes.List{ElementType: tftypes.String}, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(true), + }) + expectation := NewListNull(StringType{}) + + got, err := ListType{ElemType: StringType{}}.ValueFromTerraform(context.Background(), input) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + if !got.Equal(expectation) { + t.Errorf("Expected %+v, got %+v", expectation, got) + } +} diff --git a/types/basetypes/list_value.go b/types/basetypes/list_value.go index d0ec0302..9325d587 100644 --- a/types/basetypes/list_value.go +++ b/types/basetypes/list_value.go @@ -14,9 +14,14 @@ import ( "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/internal/reflect" "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/types/refinement" + tfrefinement "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" ) -var _ ListValuable = &ListValue{} +var ( + _ ListValuable = &ListValue{} + _ attr.ValueWithNotNullRefinement = &ListValue{} +) // ListValuable extends attr.Value for list value types. // Implement this interface to create a custom List value type. @@ -162,6 +167,10 @@ type ListValue struct { // state represents whether the value is null, unknown, or known. The // zero-value is null. state attr.ValueState + + // refinements represents the unknown value refinement data associated with this Value. + // This field is only populated for unknown values. + refinements refinement.Refinements } // Elements returns a copy of the collection of elements for the List. @@ -242,7 +251,24 @@ func (l ListValue) ToTerraformValue(ctx context.Context) (tftypes.Value, error) case attr.ValueStateNull: return tftypes.NewValue(listType, nil), nil case attr.ValueStateUnknown: - return tftypes.NewValue(listType, tftypes.UnknownValue), nil + if len(l.refinements) == 0 { + return tftypes.NewValue(listType, tftypes.UnknownValue), nil + } + + unknownValRefinements := make(tfrefinement.Refinements, 0) + for _, refn := range l.refinements { + switch refnVal := refn.(type) { + case refinement.NotNull: + unknownValRefinements[tfrefinement.KeyNullness] = tfrefinement.NewNullness(false) + case refinement.CollectionLengthLowerBound: + unknownValRefinements[tfrefinement.KeyCollectionLengthLowerBound] = tfrefinement.NewCollectionLengthLowerBound(refnVal.LowerBound()) + case refinement.CollectionLengthUpperBound: + unknownValRefinements[tfrefinement.KeyCollectionLengthUpperBound] = tfrefinement.NewCollectionLengthUpperBound(refnVal.UpperBound()) + } + } + unknownVal := tftypes.NewValue(listType, tftypes.UnknownValue) + + return unknownVal.Refine(unknownValRefinements), nil default: panic(fmt.Sprintf("unhandled List state in ToTerraformValue: %s", l.state)) } @@ -271,6 +297,14 @@ func (l ListValue) Equal(o attr.Value) bool { return false } + if len(l.refinements) != len(other.refinements) { + return false + } + + if len(l.refinements) > 0 && !l.refinements.Equal(other.refinements) { + return false + } + if l.state != attr.ValueStateKnown { return true } @@ -307,7 +341,11 @@ func (l ListValue) IsUnknown() bool { // and is intended for logging and error reporting. func (l ListValue) String() string { if l.IsUnknown() { - return attr.UnknownValueString + if len(l.refinements) == 0 { + return attr.UnknownValueString + } + + return fmt.Sprintf("", l.refinements.String()) } if l.IsNull() { @@ -332,3 +370,144 @@ func (l ListValue) String() string { func (l ListValue) ToListValue(context.Context) (ListValue, diag.Diagnostics) { return l, nil } + +// RefineAsNotNull will return a new unknown ListValue that includes a value refinement that: +// - Indicates the list value will not be null once it becomes known. +// +// If the provided ListValue is null or known, then the ListValue will be returned unchanged. +func (l ListValue) RefineAsNotNull() ListValue { + if !l.IsUnknown() { + return l + } + + newRefinements := make(refinement.Refinements, len(l.refinements)) + for i, refn := range l.refinements { + newRefinements[i] = refn + } + + newRefinements[refinement.KeyNotNull] = refinement.NewNotNull() + + newUnknownVal := NewListUnknown(l.ElementType(context.Background())) + newUnknownVal.refinements = newRefinements + + return newUnknownVal +} + +// RefineWithLengthLowerBound will return an unknown ListValue that includes a value refinement that: +// - Indicates the list value will not be null once it becomes known. +// - Indicates the length of the list value will be at least the int64 provided (lowerBound) once it becomes known. +// +// If the provided ListValue is null or known, then the ListValue will be returned unchanged. +func (l ListValue) RefineWithLengthLowerBound(lowerBound int64) ListValue { + if !l.IsUnknown() { + return l + } + + newRefinements := make(refinement.Refinements, len(l.refinements)) + for i, refn := range l.refinements { + newRefinements[i] = refn + } + + newRefinements[refinement.KeyNotNull] = refinement.NewNotNull() + newRefinements[refinement.KeyCollectionLengthLowerBound] = refinement.NewCollectionLengthLowerBound(lowerBound) + + newUnknownVal := NewListUnknown(l.ElementType(context.Background())) + newUnknownVal.refinements = newRefinements + + return newUnknownVal +} + +// RefineWithLengthUpperBound will return an unknown ListValue that includes a value refinement that: +// - Indicates the list value will not be null once it becomes known. +// - Indicates the length of the list value will be at most the int64 provided (upperBound) once it becomes known. +// +// If the provided ListValue is null or known, then the ListValue will be returned unchanged. +func (l ListValue) RefineWithLengthUpperBound(upperBound int64) ListValue { + if !l.IsUnknown() { + return l + } + + newRefinements := make(refinement.Refinements, len(l.refinements)) + for i, refn := range l.refinements { + newRefinements[i] = refn + } + + newRefinements[refinement.KeyNotNull] = refinement.NewNotNull() + newRefinements[refinement.KeyCollectionLengthUpperBound] = refinement.NewCollectionLengthUpperBound(upperBound) + + newUnknownVal := NewListUnknown(l.ElementType(context.Background())) + newUnknownVal.refinements = newRefinements + + return newUnknownVal +} + +// NotNullRefinement returns value refinement data and a boolean indicating if a NotNull refinement +// exists on the given ListValue. If a ListValue contains a NotNull refinement, this indicates +// that the list is unknown, but the eventual known value will not be null. +// +// A NotNull value refinement can be added to an unknown value via the `RefineAsNotNull` method. +func (l ListValue) NotNullRefinement() (*refinement.NotNull, bool) { + if !l.IsUnknown() { + return nil, false + } + + refn, ok := l.refinements[refinement.KeyNotNull] + if !ok { + return nil, false + } + + notNullRefn, ok := refn.(refinement.NotNull) + if !ok { + return nil, false + } + + return ¬NullRefn, true +} + +// LengthLowerBoundRefinement returns value refinement data and a boolean indicating if a CollectionLengthLowerBound refinement +// exists on the given ListValue. If a ListValue contains a CollectionLengthLowerBound refinement, this indicates that +// the list value is unknown, but the eventual known list will have a length of at least the specified int64 value once it +// becomes known. The returned boolean should be checked before accessing refinement data. +// +// A CollectionLengthLowerBound value refinement can be added to an unknown value via the `RefineWithLengthLowerBound` method. +func (l ListValue) LengthLowerBoundRefinement() (*refinement.CollectionLengthLowerBound, bool) { + if !l.IsUnknown() { + return nil, false + } + + refn, ok := l.refinements[refinement.KeyCollectionLengthLowerBound] + if !ok { + return nil, false + } + + lowerBoundRefn, ok := refn.(refinement.CollectionLengthLowerBound) + if !ok { + return nil, false + } + + return &lowerBoundRefn, true +} + +// LengthUpperBoundRefinement returns value refinement data and a boolean indicating if a CollectionLengthUpperBound refinement +// exists on the given ListValue. If a ListValue contains a CollectionLengthUpperBound refinement, this indicates that +// the list value is unknown, but the eventual known list will have a length at most the specified int64 value once it +// becomes known. The returned boolean should be checked before accessing refinement data. +// +// A CollectionLengthUpperBound value refinement can be added to an unknown value via the `RefineWithLengthUpperBound` method. +func (l ListValue) LengthUpperBoundRefinement() (*refinement.CollectionLengthUpperBound, bool) { + if !l.IsUnknown() { + return nil, false + } + + refn, ok := l.refinements[refinement.KeyCollectionLengthUpperBound] + if !ok { + return nil, false + } + + upperBoundRefn, ok := refn.(refinement.CollectionLengthUpperBound) + if !ok { + return nil, false + } + + return &upperBoundRefn, true +} diff --git a/types/basetypes/list_value_test.go b/types/basetypes/list_value_test.go index e63eb808..0877eac0 100644 --- a/types/basetypes/list_value_test.go +++ b/types/basetypes/list_value_test.go @@ -9,10 +9,12 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform-plugin-go/tftypes" + tfrefinement "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/types/refinement" ) func TestNewListValue(t *testing.T) { @@ -299,6 +301,34 @@ func TestListValueToTerraformValue(t *testing.T) { input: NewListUnknown(StringType{}), expectation: tftypes.NewValue(tftypes.List{ElementType: tftypes.String}, tftypes.UnknownValue), }, + "unknown-with-notnull-refinement": { + input: NewListUnknown(StringType{}).RefineAsNotNull(), + expectation: tftypes.NewValue(tftypes.List{ElementType: tftypes.String}, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + }), + }, + "unknown-with-length-lower-bound-refinement": { + input: NewListUnknown(StringType{}).RefineWithLengthLowerBound(5), + expectation: tftypes.NewValue(tftypes.List{ElementType: tftypes.String}, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + tfrefinement.KeyCollectionLengthLowerBound: tfrefinement.NewCollectionLengthLowerBound(5), + }), + }, + "unknown-with-length-upper-bound-refinement": { + input: NewListUnknown(StringType{}).RefineWithLengthUpperBound(10), + expectation: tftypes.NewValue(tftypes.List{ElementType: tftypes.String}, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + tfrefinement.KeyCollectionLengthUpperBound: tfrefinement.NewCollectionLengthUpperBound(10), + }), + }, + "unknown-with-both-length-bound-refinements": { + input: NewListUnknown(StringType{}).RefineWithLengthLowerBound(5).RefineWithLengthUpperBound(10), + expectation: tftypes.NewValue(tftypes.List{ElementType: tftypes.String}, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + tfrefinement.KeyCollectionLengthLowerBound: tfrefinement.NewCollectionLengthLowerBound(5), + tfrefinement.KeyCollectionLengthUpperBound: tfrefinement.NewCollectionLengthUpperBound(10), + }), + }, "null": { input: NewListNull(StringType{}), expectation: tftypes.NewValue(tftypes.List{ElementType: tftypes.String}, nil), @@ -633,6 +663,56 @@ func TestListValueEqual(t *testing.T) { input: ListValue{}, expected: false, }, + "unknown-unknown-with-notnull-refinement": { + receiver: NewListUnknown(StringType{}), + input: NewListUnknown(StringType{}).RefineAsNotNull(), + expected: false, + }, + "unknown-unknown-with-length-lowerbound-refinement": { + receiver: NewListUnknown(StringType{}), + input: NewListUnknown(StringType{}).RefineWithLengthLowerBound(5), + expected: false, + }, + "unknown-unknown-with-length-upperbound-refinement": { + receiver: NewListUnknown(StringType{}), + input: NewListUnknown(StringType{}).RefineWithLengthUpperBound(10), + expected: false, + }, + "unknowns-with-matching-notnull-refinements": { + receiver: NewListUnknown(StringType{}).RefineAsNotNull(), + input: NewListUnknown(StringType{}).RefineAsNotNull(), + expected: true, + }, + "unknowns-with-matching-length-lowerbound-refinements": { + receiver: NewListUnknown(StringType{}).RefineWithLengthLowerBound(5), + input: NewListUnknown(StringType{}).RefineWithLengthLowerBound(5), + expected: true, + }, + "unknowns-with-different-length-lowerbound-refinements": { + receiver: NewListUnknown(StringType{}).RefineWithLengthLowerBound(5), + input: NewListUnknown(StringType{}).RefineWithLengthLowerBound(6), + expected: false, + }, + "unknowns-with-matching-length-upperbound-refinements": { + receiver: NewListUnknown(StringType{}).RefineWithLengthUpperBound(10), + input: NewListUnknown(StringType{}).RefineWithLengthUpperBound(10), + expected: true, + }, + "unknowns-with-different-length-upperbound-refinements": { + receiver: NewListUnknown(StringType{}).RefineWithLengthUpperBound(10), + input: NewListUnknown(StringType{}).RefineWithLengthUpperBound(11), + expected: false, + }, + "unknowns-with-matching-both-length-bound-refinements": { + receiver: NewListUnknown(StringType{}).RefineWithLengthLowerBound(5).RefineWithLengthUpperBound(10), + input: NewListUnknown(StringType{}).RefineWithLengthLowerBound(5).RefineWithLengthUpperBound(10), + expected: true, + }, + "unknowns-with-different-both-length-bound-refinements": { + receiver: NewListUnknown(StringType{}).RefineWithLengthLowerBound(5).RefineWithLengthUpperBound(10), + input: NewListUnknown(StringType{}).RefineWithLengthLowerBound(5).RefineWithLengthUpperBound(11), + expected: false, + }, } for name, test := range tests { name, test := name, test @@ -765,6 +845,22 @@ func TestListValueString(t *testing.T) { input: NewListUnknown(StringType{}), expectation: "", }, + "unknown-with-notnull-refinement": { + input: NewListUnknown(StringType{}).RefineAsNotNull(), + expectation: "", + }, + "unknown-with-length-lowerbound-refinement": { + input: NewListUnknown(StringType{}).RefineWithLengthLowerBound(5), + expectation: ``, + }, + "unknown-with-length-upperbound-refinement": { + input: NewListUnknown(StringType{}).RefineWithLengthUpperBound(10), + expectation: ``, + }, + "unknown-with-both-length-bound-refinements": { + input: NewListUnknown(StringType{}).RefineWithLengthLowerBound(5).RefineWithLengthUpperBound(10), + expectation: ``, + }, "null": { input: NewListNull(StringType{}), expectation: "", @@ -912,3 +1008,162 @@ func TestListTypeValidate(t *testing.T) { }) } } + +func TestListValue_NotNullRefinement(t *testing.T) { + t.Parallel() + + type testCase struct { + input ListValue + expectedRefnVal refinement.Refinement + expectedFound bool + } + tests := map[string]testCase{ + "known-ignored": { + input: NewListValueMust(StringType{}, []attr.Value{NewStringValue("hello")}).RefineAsNotNull(), + expectedFound: false, + }, + "null-ignored": { + input: NewListNull(StringType{}).RefineAsNotNull(), + expectedFound: false, + }, + "unknown-no-refinement": { + input: NewListUnknown(StringType{}), + expectedFound: false, + }, + "unknown-with-notnull-refinement": { + input: NewListUnknown(StringType{}).RefineAsNotNull(), + expectedRefnVal: refinement.NewNotNull(), + expectedFound: true, + }, + } + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, found := test.input.NotNullRefinement() + if found != test.expectedFound { + t.Fatalf("Expected refinement exists to be: %t, got: %t", test.expectedFound, found) + } + + if got == nil && test.expectedRefnVal == nil { + // Success! + return + } + + if got == nil && test.expectedRefnVal != nil { + t.Fatalf("Expected refinement data: <%+v>, got: nil", test.expectedRefnVal) + } + + if diff := cmp.Diff(*got, test.expectedRefnVal); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestListValue_LengthLowerBoundRefinement(t *testing.T) { + t.Parallel() + + type testCase struct { + input ListValue + expectedRefnVal refinement.Refinement + expectedFound bool + } + tests := map[string]testCase{ + "known-ignored": { + input: NewListValueMust(StringType{}, []attr.Value{NewStringValue("hello")}).RefineWithLengthLowerBound(5), + expectedFound: false, + }, + "null-ignored": { + input: NewListNull(StringType{}).RefineWithLengthLowerBound(5), + expectedFound: false, + }, + "unknown-no-refinement": { + input: NewListUnknown(StringType{}), + expectedFound: false, + }, + "unknown-with-length-lowerbound-refinement": { + input: NewListUnknown(StringType{}).RefineWithLengthLowerBound(5), + expectedRefnVal: refinement.NewCollectionLengthLowerBound(5), + expectedFound: true, + }, + } + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, found := test.input.LengthLowerBoundRefinement() + if found != test.expectedFound { + t.Fatalf("Expected refinement exists to be: %t, got: %t", test.expectedFound, found) + } + + if got == nil && test.expectedRefnVal == nil { + // Success! + return + } + + if got == nil && test.expectedRefnVal != nil { + t.Fatalf("Expected refinement data: <%+v>, got: nil", test.expectedRefnVal) + } + + if diff := cmp.Diff(*got, test.expectedRefnVal); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestListValue_LengthUpperBoundRefinement(t *testing.T) { + t.Parallel() + + type testCase struct { + input ListValue + expectedRefnVal refinement.Refinement + expectedFound bool + } + tests := map[string]testCase{ + "known-ignored": { + input: NewListValueMust(StringType{}, []attr.Value{NewStringValue("hello")}).RefineWithLengthUpperBound(10), + expectedFound: false, + }, + "null-ignored": { + input: NewListNull(StringType{}).RefineWithLengthUpperBound(10), + expectedFound: false, + }, + "unknown-no-refinement": { + input: NewListUnknown(StringType{}), + expectedFound: false, + }, + "unknown-with-length-upperbound-refinement": { + input: NewListUnknown(StringType{}).RefineWithLengthUpperBound(10), + expectedRefnVal: refinement.NewCollectionLengthUpperBound(10), + expectedFound: true, + }, + } + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, found := test.input.LengthUpperBoundRefinement() + if found != test.expectedFound { + t.Fatalf("Expected refinement exists to be: %t, got: %t", test.expectedFound, found) + } + + if got == nil && test.expectedRefnVal == nil { + // Success! + return + } + + if got == nil && test.expectedRefnVal != nil { + t.Fatalf("Expected refinement data: <%+v>, got: nil", test.expectedRefnVal) + } + + if diff := cmp.Diff(*got, test.expectedRefnVal); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/types/basetypes/map_type.go b/types/basetypes/map_type.go index d7997a68..1e99e862 100644 --- a/types/basetypes/map_type.go +++ b/types/basetypes/map_type.go @@ -8,6 +8,7 @@ import ( "fmt" "github.com/hashicorp/terraform-plugin-go/tftypes" + tfrefinement "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/attr/xattr" @@ -87,7 +88,33 @@ func (m MapType) ValueFromTerraform(ctx context.Context, in tftypes.Value) (attr return nil, fmt.Errorf("can't use %s as value of Map with ElementType %T, can only use %s values", in.String(), m.ElementType(), m.ElementType().TerraformType(ctx).String()) } if !in.IsKnown() { - return NewMapUnknown(m.ElementType()), nil + unknownVal := NewMapUnknown(m.ElementType()) + refinements := in.Refinements() + + if len(refinements) == 0 { + return unknownVal, nil + } + + for _, refn := range refinements { + switch refnVal := refn.(type) { + case tfrefinement.Nullness: + if !refnVal.Nullness() { + unknownVal = unknownVal.RefineAsNotNull() + } else { + // This scenario shouldn't occur, as Terraform should have already collapsed an + // unknown value with a definitely null refinement into a known null value. However, + // the protocol encoding does support this refinement value, so we'll also just collapse + // it into a known null value here. + return NewMapNull(m.ElementType()), nil + } + case tfrefinement.CollectionLengthLowerBound: + unknownVal = unknownVal.RefineWithLengthLowerBound(refnVal.LowerBound()) + case tfrefinement.CollectionLengthUpperBound: + unknownVal = unknownVal.RefineWithLengthUpperBound(refnVal.UpperBound()) + } + } + + return unknownVal, nil } if in.IsNull() { return NewMapNull(m.ElementType()), nil diff --git a/types/basetypes/map_type_test.go b/types/basetypes/map_type_test.go index f3bc1bd9..c3048eb9 100644 --- a/types/basetypes/map_type_test.go +++ b/types/basetypes/map_type_test.go @@ -11,6 +11,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-go/tftypes" + tfrefinement "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" ) func TestMapTypeElementType(t *testing.T) { @@ -174,6 +175,46 @@ func TestMapTypeValueFromTerraform(t *testing.T) { }, tftypes.UnknownValue), expected: NewMapUnknown(NumberType{}), }, + "unknown-with-notnull-refinement": { + receiver: MapType{ + ElemType: StringType{}, + }, + input: tftypes.NewValue(tftypes.Map{ElementType: tftypes.String}, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + }), + expected: NewMapUnknown(StringType{}).RefineAsNotNull(), + }, + "unknown-with-length-lowerbound-refinement": { + receiver: MapType{ + ElemType: StringType{}, + }, + input: tftypes.NewValue(tftypes.Map{ElementType: tftypes.String}, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + tfrefinement.KeyCollectionLengthLowerBound: tfrefinement.NewCollectionLengthLowerBound(5), + }), + expected: NewMapUnknown(StringType{}).RefineWithLengthLowerBound(5), + }, + "unknown-with-length-upperbound-refinement": { + receiver: MapType{ + ElemType: StringType{}, + }, + input: tftypes.NewValue(tftypes.Map{ElementType: tftypes.String}, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + tfrefinement.KeyCollectionLengthUpperBound: tfrefinement.NewCollectionLengthUpperBound(10), + }), + expected: NewMapUnknown(StringType{}).RefineWithLengthUpperBound(10), + }, + "unknown-with-both-length-bound-refinements": { + receiver: MapType{ + ElemType: StringType{}, + }, + input: tftypes.NewValue(tftypes.Map{ElementType: tftypes.String}, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + tfrefinement.KeyCollectionLengthLowerBound: tfrefinement.NewCollectionLengthLowerBound(5), + tfrefinement.KeyCollectionLengthUpperBound: tfrefinement.NewCollectionLengthUpperBound(10), + }), + expected: NewMapUnknown(StringType{}).RefineWithLengthLowerBound(5).RefineWithLengthUpperBound(10), + }, "null": { receiver: MapType{ ElemType: NumberType{}, @@ -319,3 +360,23 @@ func TestMapTypeString(t *testing.T) { }) } } + +func TestMapTypeValueFromTerraform_RefinementNullCollapse(t *testing.T) { + t.Parallel() + + // This shouldn't happen, but this test ensures that if we receive this kind of refinement, that we will + // convert it to a known null value. + input := tftypes.NewValue(tftypes.Map{ElementType: tftypes.String}, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(true), + }) + expectation := NewMapNull(StringType{}) + + got, err := MapType{ElemType: StringType{}}.ValueFromTerraform(context.Background(), input) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + if !got.Equal(expectation) { + t.Errorf("Expected %+v, got %+v", expectation, got) + } +} diff --git a/types/basetypes/map_value.go b/types/basetypes/map_value.go index 7d819eca..9e809b48 100644 --- a/types/basetypes/map_value.go +++ b/types/basetypes/map_value.go @@ -15,9 +15,14 @@ import ( "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/internal/reflect" "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/types/refinement" + tfrefinement "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" ) -var _ MapValuable = &MapValue{} +var ( + _ MapValuable = &MapValue{} + _ attr.ValueWithNotNullRefinement = &MapValue{} +) // MapValuable extends attr.Value for map value types. // Implement this interface to create a custom Map value type. @@ -164,6 +169,10 @@ type MapValue struct { // state represents whether the value is null, unknown, or known. The // zero-value is null. state attr.ValueState + + // refinements represents the unknown value refinement data associated with this Value. + // This field is only populated for unknown values. + refinements refinement.Refinements } // Elements returns a copy of the mapping of elements for the Map. @@ -249,7 +258,24 @@ func (m MapValue) ToTerraformValue(ctx context.Context) (tftypes.Value, error) { case attr.ValueStateNull: return tftypes.NewValue(mapType, nil), nil case attr.ValueStateUnknown: - return tftypes.NewValue(mapType, tftypes.UnknownValue), nil + if len(m.refinements) == 0 { + return tftypes.NewValue(mapType, tftypes.UnknownValue), nil + } + + unknownValRefinements := make(tfrefinement.Refinements, 0) + for _, refn := range m.refinements { + switch refnVal := refn.(type) { + case refinement.NotNull: + unknownValRefinements[tfrefinement.KeyNullness] = tfrefinement.NewNullness(false) + case refinement.CollectionLengthLowerBound: + unknownValRefinements[tfrefinement.KeyCollectionLengthLowerBound] = tfrefinement.NewCollectionLengthLowerBound(refnVal.LowerBound()) + case refinement.CollectionLengthUpperBound: + unknownValRefinements[tfrefinement.KeyCollectionLengthUpperBound] = tfrefinement.NewCollectionLengthUpperBound(refnVal.UpperBound()) + } + } + unknownVal := tftypes.NewValue(mapType, tftypes.UnknownValue) + + return unknownVal.Refine(unknownValRefinements), nil default: panic(fmt.Sprintf("unhandled Map state in ToTerraformValue: %s", m.state)) } @@ -278,6 +304,14 @@ func (m MapValue) Equal(o attr.Value) bool { return false } + if len(m.refinements) != len(other.refinements) { + return false + } + + if len(m.refinements) > 0 && !m.refinements.Equal(other.refinements) { + return false + } + if m.state != attr.ValueStateKnown { return true } @@ -314,7 +348,11 @@ func (m MapValue) IsUnknown() bool { // and is intended for logging and error reporting. func (m MapValue) String() string { if m.IsUnknown() { - return attr.UnknownValueString + if len(m.refinements) == 0 { + return attr.UnknownValueString + } + + return fmt.Sprintf("", m.refinements.String()) } if m.IsNull() { @@ -346,3 +384,144 @@ func (m MapValue) String() string { func (m MapValue) ToMapValue(context.Context) (MapValue, diag.Diagnostics) { return m, nil } + +// RefineAsNotNull will return a new unknown MapValue that includes a value refinement that: +// - Indicates the map value will not be null once it becomes known. +// +// If the provided MapValue is null or known, then the MapValue will be returned unchanged. +func (m MapValue) RefineAsNotNull() MapValue { + if !m.IsUnknown() { + return m + } + + newRefinements := make(refinement.Refinements, len(m.refinements)) + for i, refn := range m.refinements { + newRefinements[i] = refn + } + + newRefinements[refinement.KeyNotNull] = refinement.NewNotNull() + + newUnknownVal := NewMapUnknown(m.ElementType(context.Background())) + newUnknownVal.refinements = newRefinements + + return newUnknownVal +} + +// RefineWithLengthLowerBound will return an unknown MapValue that includes a value refinement that: +// - Indicates the map value will not be null once it becomes known. +// - Indicates the length of the map value will be at least the int64 provided (lowerBound) once it becomes known. +// +// If the provided MapValue is null or known, then the MapValue will be returned unchanged. +func (m MapValue) RefineWithLengthLowerBound(lowerBound int64) MapValue { + if !m.IsUnknown() { + return m + } + + newRefinements := make(refinement.Refinements, len(m.refinements)) + for i, refn := range m.refinements { + newRefinements[i] = refn + } + + newRefinements[refinement.KeyNotNull] = refinement.NewNotNull() + newRefinements[refinement.KeyCollectionLengthLowerBound] = refinement.NewCollectionLengthLowerBound(lowerBound) + + newUnknownVal := NewMapUnknown(m.ElementType(context.Background())) + newUnknownVal.refinements = newRefinements + + return newUnknownVal +} + +// RefineWithLengthUpperBound will return an unknown MapValue that includes a value refinement that: +// - Indicates the map value will not be null once it becomes known. +// - Indicates the length of the map value will be at most the int64 provided (upperBound) once it becomes known. +// +// If the provided MapValue is null or known, then the MapValue will be returned unchanged. +func (m MapValue) RefineWithLengthUpperBound(upperBound int64) MapValue { + if !m.IsUnknown() { + return m + } + + newRefinements := make(refinement.Refinements, len(m.refinements)) + for i, refn := range m.refinements { + newRefinements[i] = refn + } + + newRefinements[refinement.KeyNotNull] = refinement.NewNotNull() + newRefinements[refinement.KeyCollectionLengthUpperBound] = refinement.NewCollectionLengthUpperBound(upperBound) + + newUnknownVal := NewMapUnknown(m.ElementType(context.Background())) + newUnknownVal.refinements = newRefinements + + return newUnknownVal +} + +// NotNullRefinement returns value refinement data and a boolean indicating if a NotNull refinement +// exists on the given MapValue. If a MapValue contains a NotNull refinement, this indicates +// that the map is unknown, but the eventual known value will not be null. +// +// A NotNull value refinement can be added to an unknown value via the `RefineAsNotNull` method. +func (m MapValue) NotNullRefinement() (*refinement.NotNull, bool) { + if !m.IsUnknown() { + return nil, false + } + + refn, ok := m.refinements[refinement.KeyNotNull] + if !ok { + return nil, false + } + + notNullRefn, ok := refn.(refinement.NotNull) + if !ok { + return nil, false + } + + return ¬NullRefn, true +} + +// LengthLowerBoundRefinement returns value refinement data and a boolean indicating if a CollectionLengthLowerBound refinement +// exists on the given MapValue. If a MapValue contains a CollectionLengthLowerBound refinement, this indicates that +// the map value is unknown, but the eventual known map will have a length of at least the specified int64 value once it +// becomes known. The returned boolean should be checked before accessing refinement data. +// +// A CollectionLengthLowerBound value refinement can be added to an unknown value via the `RefineWithLengthLowerBound` method. +func (m MapValue) LengthLowerBoundRefinement() (*refinement.CollectionLengthLowerBound, bool) { + if !m.IsUnknown() { + return nil, false + } + + refn, ok := m.refinements[refinement.KeyCollectionLengthLowerBound] + if !ok { + return nil, false + } + + lowerBoundRefn, ok := refn.(refinement.CollectionLengthLowerBound) + if !ok { + return nil, false + } + + return &lowerBoundRefn, true +} + +// LengthUpperBoundRefinement returns value refinement data and a boolean indicating if a CollectionLengthUpperBound refinement +// exists on the given MapValue. If a MapValue contains a CollectionLengthUpperBound refinement, this indicates that +// the map value is unknown, but the eventual known map will have a length at most the specified int64 value once it +// becomes known. The returned boolean should be checked before accessing refinement data. +// +// A CollectionLengthUpperBound value refinement can be added to an unknown value via the `RefineWithLengthUpperBound` method. +func (m MapValue) LengthUpperBoundRefinement() (*refinement.CollectionLengthUpperBound, bool) { + if !m.IsUnknown() { + return nil, false + } + + refn, ok := m.refinements[refinement.KeyCollectionLengthUpperBound] + if !ok { + return nil, false + } + + upperBoundRefn, ok := refn.(refinement.CollectionLengthUpperBound) + if !ok { + return nil, false + } + + return &upperBoundRefn, true +} diff --git a/types/basetypes/map_value_test.go b/types/basetypes/map_value_test.go index 0debecd9..b87eb749 100644 --- a/types/basetypes/map_value_test.go +++ b/types/basetypes/map_value_test.go @@ -9,10 +9,12 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform-plugin-go/tftypes" + tfrefinement "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/types/refinement" ) func TestNewMapValue(t *testing.T) { @@ -302,6 +304,34 @@ func TestMapValueToTerraformValue(t *testing.T) { input: NewMapUnknown(StringType{}), expectation: tftypes.NewValue(tftypes.Map{ElementType: tftypes.String}, tftypes.UnknownValue), }, + "unknown-with-notnull-refinement": { + input: NewMapUnknown(StringType{}).RefineAsNotNull(), + expectation: tftypes.NewValue(tftypes.Map{ElementType: tftypes.String}, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + }), + }, + "unknown-with-length-lower-bound-refinement": { + input: NewMapUnknown(StringType{}).RefineWithLengthLowerBound(5), + expectation: tftypes.NewValue(tftypes.Map{ElementType: tftypes.String}, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + tfrefinement.KeyCollectionLengthLowerBound: tfrefinement.NewCollectionLengthLowerBound(5), + }), + }, + "unknown-with-length-upper-bound-refinement": { + input: NewMapUnknown(StringType{}).RefineWithLengthUpperBound(10), + expectation: tftypes.NewValue(tftypes.Map{ElementType: tftypes.String}, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + tfrefinement.KeyCollectionLengthUpperBound: tfrefinement.NewCollectionLengthUpperBound(10), + }), + }, + "unknown-with-both-length-bound-refinements": { + input: NewMapUnknown(StringType{}).RefineWithLengthLowerBound(5).RefineWithLengthUpperBound(10), + expectation: tftypes.NewValue(tftypes.Map{ElementType: tftypes.String}, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + tfrefinement.KeyCollectionLengthLowerBound: tfrefinement.NewCollectionLengthLowerBound(5), + tfrefinement.KeyCollectionLengthUpperBound: tfrefinement.NewCollectionLengthUpperBound(10), + }), + }, "null": { input: NewMapNull(StringType{}), expectation: tftypes.NewValue(tftypes.Map{ElementType: tftypes.String}, nil), @@ -636,6 +666,56 @@ func TestMapValueEqual(t *testing.T) { input: MapValue{}, expected: false, }, + "unknown-unknown-with-notnull-refinement": { + receiver: NewMapUnknown(StringType{}), + input: NewMapUnknown(StringType{}).RefineAsNotNull(), + expected: false, + }, + "unknown-unknown-with-length-lowerbound-refinement": { + receiver: NewMapUnknown(StringType{}), + input: NewMapUnknown(StringType{}).RefineWithLengthLowerBound(5), + expected: false, + }, + "unknown-unknown-with-length-upperbound-refinement": { + receiver: NewMapUnknown(StringType{}), + input: NewMapUnknown(StringType{}).RefineWithLengthUpperBound(10), + expected: false, + }, + "unknowns-with-matching-notnull-refinements": { + receiver: NewMapUnknown(StringType{}).RefineAsNotNull(), + input: NewMapUnknown(StringType{}).RefineAsNotNull(), + expected: true, + }, + "unknowns-with-matching-length-lowerbound-refinements": { + receiver: NewMapUnknown(StringType{}).RefineWithLengthLowerBound(5), + input: NewMapUnknown(StringType{}).RefineWithLengthLowerBound(5), + expected: true, + }, + "unknowns-with-different-length-lowerbound-refinements": { + receiver: NewMapUnknown(StringType{}).RefineWithLengthLowerBound(5), + input: NewMapUnknown(StringType{}).RefineWithLengthLowerBound(6), + expected: false, + }, + "unknowns-with-matching-length-upperbound-refinements": { + receiver: NewMapUnknown(StringType{}).RefineWithLengthUpperBound(10), + input: NewMapUnknown(StringType{}).RefineWithLengthUpperBound(10), + expected: true, + }, + "unknowns-with-different-length-upperbound-refinements": { + receiver: NewMapUnknown(StringType{}).RefineWithLengthUpperBound(10), + input: NewMapUnknown(StringType{}).RefineWithLengthUpperBound(11), + expected: false, + }, + "unknowns-with-matching-both-length-bound-refinements": { + receiver: NewMapUnknown(StringType{}).RefineWithLengthLowerBound(5).RefineWithLengthUpperBound(10), + input: NewMapUnknown(StringType{}).RefineWithLengthLowerBound(5).RefineWithLengthUpperBound(10), + expected: true, + }, + "unknowns-with-different-both-length-bound-refinements": { + receiver: NewMapUnknown(StringType{}).RefineWithLengthLowerBound(5).RefineWithLengthUpperBound(10), + input: NewMapUnknown(StringType{}).RefineWithLengthLowerBound(5).RefineWithLengthUpperBound(11), + expected: false, + }, } for name, test := range tests { name, test := name, test @@ -780,6 +860,22 @@ func TestMapValueString(t *testing.T) { input: NewMapUnknown(StringType{}), expectation: "", }, + "unknown-with-notnull-refinement": { + input: NewMapUnknown(StringType{}).RefineAsNotNull(), + expectation: "", + }, + "unknown-with-length-lowerbound-refinement": { + input: NewMapUnknown(StringType{}).RefineWithLengthLowerBound(5), + expectation: ``, + }, + "unknown-with-length-upperbound-refinement": { + input: NewMapUnknown(StringType{}).RefineWithLengthUpperBound(10), + expectation: ``, + }, + "unknown-with-both-length-bound-refinements": { + input: NewMapUnknown(StringType{}).RefineWithLengthLowerBound(5).RefineWithLengthUpperBound(10), + expectation: ``, + }, "null": { input: NewMapNull(StringType{}), expectation: "", @@ -927,3 +1023,162 @@ func TestMapTypeValidate(t *testing.T) { }) } } + +func TestMapValue_NotNullRefinement(t *testing.T) { + t.Parallel() + + type testCase struct { + input MapValue + expectedRefnVal refinement.Refinement + expectedFound bool + } + tests := map[string]testCase{ + "known-ignored": { + input: NewMapValueMust(StringType{}, map[string]attr.Value{"key": NewStringValue("hello")}).RefineAsNotNull(), + expectedFound: false, + }, + "null-ignored": { + input: NewMapNull(StringType{}).RefineAsNotNull(), + expectedFound: false, + }, + "unknown-no-refinement": { + input: NewMapUnknown(StringType{}), + expectedFound: false, + }, + "unknown-with-notnull-refinement": { + input: NewMapUnknown(StringType{}).RefineAsNotNull(), + expectedRefnVal: refinement.NewNotNull(), + expectedFound: true, + }, + } + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, found := test.input.NotNullRefinement() + if found != test.expectedFound { + t.Fatalf("Expected refinement exists to be: %t, got: %t", test.expectedFound, found) + } + + if got == nil && test.expectedRefnVal == nil { + // Success! + return + } + + if got == nil && test.expectedRefnVal != nil { + t.Fatalf("Expected refinement data: <%+v>, got: nil", test.expectedRefnVal) + } + + if diff := cmp.Diff(*got, test.expectedRefnVal); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestMapValue_LengthLowerBoundRefinement(t *testing.T) { + t.Parallel() + + type testCase struct { + input MapValue + expectedRefnVal refinement.Refinement + expectedFound bool + } + tests := map[string]testCase{ + "known-ignored": { + input: NewMapValueMust(StringType{}, map[string]attr.Value{"key": NewStringValue("hello")}).RefineWithLengthLowerBound(5), + expectedFound: false, + }, + "null-ignored": { + input: NewMapNull(StringType{}).RefineWithLengthLowerBound(5), + expectedFound: false, + }, + "unknown-no-refinement": { + input: NewMapUnknown(StringType{}), + expectedFound: false, + }, + "unknown-with-length-lowerbound-refinement": { + input: NewMapUnknown(StringType{}).RefineWithLengthLowerBound(5), + expectedRefnVal: refinement.NewCollectionLengthLowerBound(5), + expectedFound: true, + }, + } + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, found := test.input.LengthLowerBoundRefinement() + if found != test.expectedFound { + t.Fatalf("Expected refinement exists to be: %t, got: %t", test.expectedFound, found) + } + + if got == nil && test.expectedRefnVal == nil { + // Success! + return + } + + if got == nil && test.expectedRefnVal != nil { + t.Fatalf("Expected refinement data: <%+v>, got: nil", test.expectedRefnVal) + } + + if diff := cmp.Diff(*got, test.expectedRefnVal); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestMapValue_LengthUpperBoundRefinement(t *testing.T) { + t.Parallel() + + type testCase struct { + input MapValue + expectedRefnVal refinement.Refinement + expectedFound bool + } + tests := map[string]testCase{ + "known-ignored": { + input: NewMapValueMust(StringType{}, map[string]attr.Value{"key": NewStringValue("hello")}).RefineWithLengthUpperBound(10), + expectedFound: false, + }, + "null-ignored": { + input: NewMapNull(StringType{}).RefineWithLengthUpperBound(10), + expectedFound: false, + }, + "unknown-no-refinement": { + input: NewMapUnknown(StringType{}), + expectedFound: false, + }, + "unknown-with-length-upperbound-refinement": { + input: NewMapUnknown(StringType{}).RefineWithLengthUpperBound(10), + expectedRefnVal: refinement.NewCollectionLengthUpperBound(10), + expectedFound: true, + }, + } + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, found := test.input.LengthUpperBoundRefinement() + if found != test.expectedFound { + t.Fatalf("Expected refinement exists to be: %t, got: %t", test.expectedFound, found) + } + + if got == nil && test.expectedRefnVal == nil { + // Success! + return + } + + if got == nil && test.expectedRefnVal != nil { + t.Fatalf("Expected refinement data: <%+v>, got: nil", test.expectedRefnVal) + } + + if diff := cmp.Diff(*got, test.expectedRefnVal); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/types/basetypes/number_type.go b/types/basetypes/number_type.go index 3cd2a92f..3cd6fa19 100644 --- a/types/basetypes/number_type.go +++ b/types/basetypes/number_type.go @@ -11,6 +11,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-go/tftypes" + tfrefinement "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" ) // NumberTypable extends attr.Type for number types. @@ -62,7 +63,33 @@ func (t NumberType) ValueFromNumber(_ context.Context, v NumberValue) (NumberVal // consume the data with. func (t NumberType) ValueFromTerraform(ctx context.Context, in tftypes.Value) (attr.Value, error) { if !in.IsKnown() { - return NewNumberUnknown(), nil + unknownVal := NewNumberUnknown() + refinements := in.Refinements() + + if len(refinements) == 0 { + return unknownVal, nil + } + + for _, refn := range refinements { + switch refnVal := refn.(type) { + case tfrefinement.Nullness: + if !refnVal.Nullness() { + unknownVal = unknownVal.RefineAsNotNull() + } else { + // This scenario shouldn't occur, as Terraform should have already collapsed an + // unknown value with a definitely null refinement into a known null value. However, + // the protocol encoding does support this refinement value, so we'll also just collapse + // it into a known null value here. + return NewNumberNull(), nil + } + case tfrefinement.NumberLowerBound: + unknownVal = unknownVal.RefineWithLowerBound(refnVal.LowerBound(), refnVal.IsInclusive()) + case tfrefinement.NumberUpperBound: + unknownVal = unknownVal.RefineWithUpperBound(refnVal.UpperBound(), refnVal.IsInclusive()) + } + } + + return unknownVal, nil } if in.IsNull() { diff --git a/types/basetypes/number_type_test.go b/types/basetypes/number_type_test.go index 287e7603..2c8f5c95 100644 --- a/types/basetypes/number_type_test.go +++ b/types/basetypes/number_type_test.go @@ -10,6 +10,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-go/tftypes" + tfrefinement "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" ) func TestNumberTypeValueFromTerraform(t *testing.T) { @@ -29,6 +30,34 @@ func TestNumberTypeValueFromTerraform(t *testing.T) { input: tftypes.NewValue(tftypes.Number, tftypes.UnknownValue), expectation: NewNumberUnknown(), }, + "unknown-with-notnull-refinement": { + input: tftypes.NewValue(tftypes.Number, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + }), + expectation: NewNumberUnknown().RefineAsNotNull(), + }, + "unknown-with-lowerbound-refinement": { + input: tftypes.NewValue(tftypes.Number, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + tfrefinement.KeyNumberLowerBound: tfrefinement.NewNumberLowerBound(big.NewFloat(1.23), true), + }), + expectation: NewNumberUnknown().RefineWithLowerBound(big.NewFloat(1.23), true), + }, + "unknown-with-upperbound-refinement": { + input: tftypes.NewValue(tftypes.Number, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + tfrefinement.KeyNumberUpperBound: tfrefinement.NewNumberUpperBound(big.NewFloat(4.56), false), + }), + expectation: NewNumberUnknown().RefineWithUpperBound(big.NewFloat(4.56), false), + }, + "unknown-with-both-bound-refinements": { + input: tftypes.NewValue(tftypes.Number, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + tfrefinement.KeyNumberLowerBound: tfrefinement.NewNumberLowerBound(big.NewFloat(1.23), true), + tfrefinement.KeyNumberUpperBound: tfrefinement.NewNumberUpperBound(big.NewFloat(4.56), false), + }), + expectation: NewNumberUnknown().RefineWithLowerBound(big.NewFloat(1.23), true).RefineWithUpperBound(big.NewFloat(4.56), false), + }, "null": { input: tftypes.NewValue(tftypes.Number, nil), expectation: NewNumberNull(), @@ -58,7 +87,7 @@ func TestNumberTypeValueFromTerraform(t *testing.T) { // expectations, we're good return } - if err == nil && test.expectedErr != "" { + if test.expectedErr != "" { t.Errorf("Expected error to be %q, didn't get an error", test.expectedErr) return } @@ -74,3 +103,23 @@ func TestNumberTypeValueFromTerraform(t *testing.T) { }) } } + +func TestNumberTypeValueFromTerraform_RefinementNullCollapse(t *testing.T) { + t.Parallel() + + // This shouldn't happen, but this test ensures that if we receive this kind of refinement, that we will + // convert it to a known null value. + input := tftypes.NewValue(tftypes.Number, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(true), + }) + expectation := NewNumberNull() + + got, err := NumberType{}.ValueFromTerraform(context.Background(), input) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + if !got.Equal(expectation) { + t.Errorf("Expected %+v, got %+v", expectation, got) + } +} diff --git a/types/basetypes/number_value.go b/types/basetypes/number_value.go index 28c89de5..be1b45ad 100644 --- a/types/basetypes/number_value.go +++ b/types/basetypes/number_value.go @@ -12,10 +12,13 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types/refinement" + tfrefinement "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" ) var ( - _ NumberValuable = NumberValue{} + _ NumberValuable = NumberValue{} + _ attr.ValueWithNotNullRefinement = NumberValue{} ) // NumberValuable extends attr.Value for number value types. @@ -80,6 +83,10 @@ type NumberValue struct { // value contains the known value, if not null or unknown. value *big.Float + + // refinements represents the unknown value refinement data associated with this Value. + // This field is only populated for unknown values. + refinements refinement.Refinements } // Type returns a NumberType. @@ -103,7 +110,24 @@ func (n NumberValue) ToTerraformValue(_ context.Context) (tftypes.Value, error) case attr.ValueStateNull: return tftypes.NewValue(tftypes.Number, nil), nil case attr.ValueStateUnknown: - return tftypes.NewValue(tftypes.Number, tftypes.UnknownValue), nil + if len(n.refinements) == 0 { + return tftypes.NewValue(tftypes.Number, tftypes.UnknownValue), nil + } + + unknownValRefinements := make(tfrefinement.Refinements, 0) + for _, refn := range n.refinements { + switch refnVal := refn.(type) { + case refinement.NotNull: + unknownValRefinements[tfrefinement.KeyNullness] = tfrefinement.NewNullness(false) + case refinement.NumberLowerBound: + unknownValRefinements[tfrefinement.KeyNumberLowerBound] = tfrefinement.NewNumberLowerBound(refnVal.LowerBound(), refnVal.IsInclusive()) + case refinement.NumberUpperBound: + unknownValRefinements[tfrefinement.KeyNumberUpperBound] = tfrefinement.NewNumberUpperBound(refnVal.UpperBound(), refnVal.IsInclusive()) + } + } + unknownVal := tftypes.NewValue(tftypes.Number, tftypes.UnknownValue) + + return unknownVal.Refine(unknownValRefinements), nil default: panic(fmt.Sprintf("unhandled Number state in ToTerraformValue: %s", n.state)) } @@ -121,6 +145,14 @@ func (n NumberValue) Equal(other attr.Value) bool { return false } + if len(n.refinements) != len(o.refinements) { + return false + } + + if len(n.refinements) > 0 && !n.refinements.Equal(o.refinements) { + return false + } + if n.state != attr.ValueStateKnown { return true } @@ -143,7 +175,11 @@ func (n NumberValue) IsUnknown() bool { // and is intended for logging and error reporting. func (n NumberValue) String() string { if n.IsUnknown() { - return attr.UnknownValueString + if len(n.refinements) == 0 { + return attr.UnknownValueString + } + + return fmt.Sprintf("", n.refinements.String()) } if n.IsNull() { @@ -163,3 +199,146 @@ func (n NumberValue) ValueBigFloat() *big.Float { func (n NumberValue) ToNumberValue(context.Context) (NumberValue, diag.Diagnostics) { return n, nil } + +// RefineAsNotNull will return an unknown NumberValue that includes a value refinement that: +// - Indicates the number value will not be null once it becomes known. +// +// If the provided NumberValue is null or known, then the NumberValue will be returned unchanged. +func (n NumberValue) RefineAsNotNull() NumberValue { + if !n.IsUnknown() { + return n + } + + newRefinements := make(refinement.Refinements, len(n.refinements)) + for i, refn := range n.refinements { + newRefinements[i] = refn + } + + newRefinements[refinement.KeyNotNull] = refinement.NewNotNull() + + newUnknownVal := NewNumberUnknown() + newUnknownVal.refinements = newRefinements + + return newUnknownVal +} + +// RefineWithLowerBound will return an unknown NumberValue that includes a value refinement that: +// - Indicates the number value will not be null once it becomes known. +// - Indicates the number value will not be less than the number provided (lowerBound) once it becomes known. +// +// If the provided NumberValue is null or known, then the NumberValue will be returned unchanged. +func (n NumberValue) RefineWithLowerBound(lowerBound *big.Float, inclusive bool) NumberValue { + if !n.IsUnknown() { + return n + } + + newRefinements := make(refinement.Refinements, len(n.refinements)) + for i, refn := range n.refinements { + newRefinements[i] = refn + } + + newRefinements[refinement.KeyNotNull] = refinement.NewNotNull() + newRefinements[refinement.KeyNumberLowerBound] = refinement.NewNumberLowerBound(lowerBound, inclusive) + + newUnknownVal := NewNumberUnknown() + newUnknownVal.refinements = newRefinements + + return newUnknownVal +} + +// RefineWithUpperBound will return an unknown NumberValue that includes a value refinement that: +// - Indicates the number value will not be null once it becomes known. +// - Indicates the number value will not be greater than the number provided (upperBound) once it becomes known. +// +// If the provided NumberValue is null or known, then the NumberValue will be returned unchanged. +func (n NumberValue) RefineWithUpperBound(upperBound *big.Float, inclusive bool) NumberValue { + if !n.IsUnknown() { + return n + } + + newRefinements := make(refinement.Refinements, len(n.refinements)) + for i, refn := range n.refinements { + newRefinements[i] = refn + } + + newRefinements[refinement.KeyNotNull] = refinement.NewNotNull() + newRefinements[refinement.KeyNumberUpperBound] = refinement.NewNumberUpperBound(upperBound, inclusive) + + newUnknownVal := NewNumberUnknown() + newUnknownVal.refinements = newRefinements + + return newUnknownVal +} + +// NotNullRefinement returns value refinement data and a boolean indicating if a NotNull refinement +// exists on the given NumberValue. If an NumberValue contains a NotNull refinement, this indicates that +// the number value is unknown, but the eventual known value will not be null. +// +// A NotNull value refinement can be added to an unknown value via the `RefineAsNotNull` method. +func (n NumberValue) NotNullRefinement() (*refinement.NotNull, bool) { + if !n.IsUnknown() { + return nil, false + } + + refn, ok := n.refinements[refinement.KeyNotNull] + if !ok { + return nil, false + } + + notNullRefn, ok := refn.(refinement.NotNull) + if !ok { + return nil, false + } + + return ¬NullRefn, true +} + +// LowerBoundRefinement returns value refinement data and a boolean indicating if a NumberLowerBound refinement +// exists on the given NumberValue. If an NumberValue contains a NumberLowerBound refinement, this indicates that +// the number value is unknown, but the eventual known value will not be less than the specified number value +// (either inclusive or exclusive) once it becomes known. The returned boolean should be checked before accessing +// refinement data. +// +// An NumberLowerBound value refinement can be added to an unknown value via the `RefineWithLowerBound` method. +func (n NumberValue) LowerBoundRefinement() (*refinement.NumberLowerBound, bool) { + if !n.IsUnknown() { + return nil, false + } + + refn, ok := n.refinements[refinement.KeyNumberLowerBound] + if !ok { + return nil, false + } + + lowerBoundRefn, ok := refn.(refinement.NumberLowerBound) + if !ok { + return nil, false + } + + return &lowerBoundRefn, true +} + +// UpperBoundRefinement returns value refinement data and a boolean indicating if a NumberUpperBound refinement +// exists on the given NumberValue. If an NumberValue contains a NumberUpperBound refinement, this indicates that +// the number value is unknown, but the eventual known value will not be greater than the specified number value +// (either inclusive or exclusive) once it becomes known. The returned boolean should be checked before accessing +// refinement data. +// +// A NumberUpperBound value refinement can be added to an unknown value via the `RefineWithUpperBound` method. +func (n NumberValue) UpperBoundRefinement() (*refinement.NumberUpperBound, bool) { + if !n.IsUnknown() { + return nil, false + } + + refn, ok := n.refinements[refinement.KeyNumberUpperBound] + if !ok { + return nil, false + } + + upperBoundRefn, ok := refn.(refinement.NumberUpperBound) + if !ok { + return nil, false + } + + return &upperBoundRefn, true +} diff --git a/types/basetypes/number_value_test.go b/types/basetypes/number_value_test.go index 27a25ac7..788af732 100644 --- a/types/basetypes/number_value_test.go +++ b/types/basetypes/number_value_test.go @@ -11,7 +11,9 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types/refinement" "github.com/hashicorp/terraform-plugin-go/tftypes" + tfrefinement "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" ) func numberComparer(i, j *big.Float) bool { @@ -38,6 +40,34 @@ func TestNumberValueToTerraformValue(t *testing.T) { input: NewNumberUnknown(), expectation: tftypes.NewValue(tftypes.Number, tftypes.UnknownValue), }, + "unknown-with-notnull-refinement": { + input: NewNumberUnknown().RefineAsNotNull(), + expectation: tftypes.NewValue(tftypes.Number, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + }), + }, + "unknown-with-lower-bound-refinement": { + input: NewNumberUnknown().RefineWithLowerBound(big.NewFloat(1.23), true), + expectation: tftypes.NewValue(tftypes.Number, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + tfrefinement.KeyNumberLowerBound: tfrefinement.NewNumberLowerBound(big.NewFloat(1.23), true), + }), + }, + "unknown-with-upper-bound-refinement": { + input: NewNumberUnknown().RefineWithUpperBound(big.NewFloat(4.56), false), + expectation: tftypes.NewValue(tftypes.Number, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + tfrefinement.KeyNumberUpperBound: tfrefinement.NewNumberUpperBound(big.NewFloat(4.56), false), + }), + }, + "unknown-with-both-bound-refinements": { + input: NewNumberUnknown().RefineWithLowerBound(big.NewFloat(1.23), true).RefineWithUpperBound(big.NewFloat(4.56), false), + expectation: tftypes.NewValue(tftypes.Number, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + tfrefinement.KeyNumberLowerBound: tfrefinement.NewNumberLowerBound(big.NewFloat(1.23), true), + tfrefinement.KeyNumberUpperBound: tfrefinement.NewNumberUpperBound(big.NewFloat(4.56), false), + }), + }, "null": { input: NewNumberNull(), expectation: tftypes.NewValue(tftypes.Number, nil), @@ -120,6 +150,71 @@ func TestNumberValueEqual(t *testing.T) { candidate: NewNumberUnknown(), expectation: true, }, + "unknown-unknown-with-notnull-refinement": { + input: NewNumberUnknown(), + candidate: NewNumberUnknown().RefineAsNotNull(), + expectation: false, + }, + "unknown-unknown-with-lowerbound-refinement": { + input: NewNumberUnknown(), + candidate: NewNumberUnknown().RefineWithLowerBound(big.NewFloat(1.23), true), + expectation: false, + }, + "unknown-unknown-with-upperbound-refinement": { + input: NewNumberUnknown(), + candidate: NewNumberUnknown().RefineWithUpperBound(big.NewFloat(4.56), false), + expectation: false, + }, + "unknowns-with-matching-notnull-refinements": { + input: NewNumberUnknown().RefineAsNotNull(), + candidate: NewNumberUnknown().RefineAsNotNull(), + expectation: true, + }, + "unknowns-with-matching-lowerbound-refinements": { + input: NewNumberUnknown().RefineWithLowerBound(big.NewFloat(1.23), true), + candidate: NewNumberUnknown().RefineWithLowerBound(big.NewFloat(1.23), true), + expectation: true, + }, + "unknowns-with-different-lowerbound-refinements": { + input: NewNumberUnknown().RefineWithLowerBound(big.NewFloat(1.23), true), + candidate: NewNumberUnknown().RefineWithLowerBound(big.NewFloat(1.24), true), + expectation: false, + }, + "unknowns-with-different-lowerbound-refinements-inclusive": { + input: NewNumberUnknown().RefineWithLowerBound(big.NewFloat(1.23), true), + candidate: NewNumberUnknown().RefineWithLowerBound(big.NewFloat(1.23), false), + expectation: false, + }, + "unknowns-with-matching-upperbound-refinements": { + input: NewNumberUnknown().RefineWithUpperBound(big.NewFloat(4.56), true), + candidate: NewNumberUnknown().RefineWithUpperBound(big.NewFloat(4.56), true), + expectation: true, + }, + "unknowns-with-different-upperbound-refinements": { + input: NewNumberUnknown().RefineWithUpperBound(big.NewFloat(4.56), true), + candidate: NewNumberUnknown().RefineWithUpperBound(big.NewFloat(4.57), true), + expectation: false, + }, + "unknowns-with-different-upperbound-refinements-inclusive": { + input: NewNumberUnknown().RefineWithUpperBound(big.NewFloat(4.56), true), + candidate: NewNumberUnknown().RefineWithUpperBound(big.NewFloat(4.56), false), + expectation: false, + }, + "unknowns-with-matching-both-bound-refinements": { + input: NewNumberUnknown().RefineWithLowerBound(big.NewFloat(1.23), true).RefineWithUpperBound(big.NewFloat(4.56), true), + candidate: NewNumberUnknown().RefineWithLowerBound(big.NewFloat(1.23), true).RefineWithUpperBound(big.NewFloat(4.56), true), + expectation: true, + }, + "unknowns-with-different-both-bound-refinements": { + input: NewNumberUnknown().RefineWithLowerBound(big.NewFloat(1.23), true).RefineWithUpperBound(big.NewFloat(4.56), true), + candidate: NewNumberUnknown().RefineWithLowerBound(big.NewFloat(1.23), true).RefineWithUpperBound(big.NewFloat(4.57), true), + expectation: false, + }, + "unknowns-with-different-both-bound-refinements-inclusive": { + input: NewNumberUnknown().RefineWithLowerBound(big.NewFloat(1.23), true).RefineWithUpperBound(big.NewFloat(4.56), true), + candidate: NewNumberUnknown().RefineWithLowerBound(big.NewFloat(1.23), true).RefineWithUpperBound(big.NewFloat(4.56), false), + expectation: false, + }, "unknown-null": { input: NewNumberUnknown(), candidate: NewNumberNull(), @@ -287,6 +382,22 @@ func TestNumberValueString(t *testing.T) { input: NewNumberUnknown(), expectation: "", }, + "unknown-with-notnull-refinement": { + input: NewNumberUnknown().RefineAsNotNull(), + expectation: "", + }, + "unknown-with-lowerbound-refinement": { + input: NewNumberUnknown().RefineWithLowerBound(big.NewFloat(1.23), true), + expectation: ``, + }, + "unknown-with-upperbound-refinement": { + input: NewNumberUnknown().RefineWithUpperBound(big.NewFloat(4.56), false), + expectation: ``, + }, + "unknown-with-both-bound-refinements": { + input: NewNumberUnknown().RefineWithLowerBound(big.NewFloat(1.23), true).RefineWithUpperBound(big.NewFloat(4.56), false), + expectation: ``, + }, "null": { input: NewNumberNull(), expectation: "", @@ -355,3 +466,162 @@ func TestNumberValueValueBigFloat(t *testing.T) { }) } } + +func TestNumberValue_NotNullRefinement(t *testing.T) { + t.Parallel() + + type testCase struct { + input NumberValue + expectedRefnVal refinement.Refinement + expectedFound bool + } + tests := map[string]testCase{ + "known-ignored": { + input: NewNumberValue(big.NewFloat(4.56)).RefineAsNotNull(), + expectedFound: false, + }, + "null-ignored": { + input: NewNumberNull().RefineAsNotNull(), + expectedFound: false, + }, + "unknown-no-refinement": { + input: NewNumberUnknown(), + expectedFound: false, + }, + "unknown-with-notnull-refinement": { + input: NewNumberUnknown().RefineAsNotNull(), + expectedRefnVal: refinement.NewNotNull(), + expectedFound: true, + }, + } + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, found := test.input.NotNullRefinement() + if found != test.expectedFound { + t.Fatalf("Expected refinement exists to be: %t, got: %t", test.expectedFound, found) + } + + if got == nil && test.expectedRefnVal == nil { + // Success! + return + } + + if got == nil && test.expectedRefnVal != nil { + t.Fatalf("Expected refinement data: <%+v>, got: nil", test.expectedRefnVal) + } + + if diff := cmp.Diff(*got, test.expectedRefnVal); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestNumberValue_LowerBoundRefinement(t *testing.T) { + t.Parallel() + + type testCase struct { + input NumberValue + expectedRefnVal refinement.Refinement + expectedFound bool + } + tests := map[string]testCase{ + "known-ignored": { + input: NewNumberValue(big.NewFloat(4.56)).RefineWithLowerBound(big.NewFloat(1.23), true), + expectedFound: false, + }, + "null-ignored": { + input: NewNumberNull().RefineWithLowerBound(big.NewFloat(1.23), true), + expectedFound: false, + }, + "unknown-no-refinement": { + input: NewNumberUnknown(), + expectedFound: false, + }, + "unknown-with-lowerbound-refinement": { + input: NewNumberUnknown().RefineWithLowerBound(big.NewFloat(1.23), true), + expectedRefnVal: refinement.NewNumberLowerBound(big.NewFloat(1.23), true), + expectedFound: true, + }, + } + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, found := test.input.LowerBoundRefinement() + if found != test.expectedFound { + t.Fatalf("Expected refinement exists to be: %t, got: %t", test.expectedFound, found) + } + + if got == nil && test.expectedRefnVal == nil { + // Success! + return + } + + if got == nil && test.expectedRefnVal != nil { + t.Fatalf("Expected refinement data: <%+v>, got: nil", test.expectedRefnVal) + } + + if diff := cmp.Diff(*got, test.expectedRefnVal); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestNumberValue_UpperBoundRefinement(t *testing.T) { + t.Parallel() + + type testCase struct { + input NumberValue + expectedRefnVal refinement.Refinement + expectedFound bool + } + tests := map[string]testCase{ + "known-ignored": { + input: NewNumberValue(big.NewFloat(4.56)).RefineWithUpperBound(big.NewFloat(1.23), true), + expectedFound: false, + }, + "null-ignored": { + input: NewNumberNull().RefineWithUpperBound(big.NewFloat(1.23), true), + expectedFound: false, + }, + "unknown-no-refinement": { + input: NewNumberUnknown(), + expectedFound: false, + }, + "unknown-with-upperbound-refinement": { + input: NewNumberUnknown().RefineWithUpperBound(big.NewFloat(1.23), true), + expectedRefnVal: refinement.NewNumberUpperBound(big.NewFloat(1.23), true), + expectedFound: true, + }, + } + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, found := test.input.UpperBoundRefinement() + if found != test.expectedFound { + t.Fatalf("Expected refinement exists to be: %t, got: %t", test.expectedFound, found) + } + + if got == nil && test.expectedRefnVal == nil { + // Success! + return + } + + if got == nil && test.expectedRefnVal != nil { + t.Fatalf("Expected refinement data: <%+v>, got: nil", test.expectedRefnVal) + } + + if diff := cmp.Diff(*got, test.expectedRefnVal); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/types/basetypes/object_type.go b/types/basetypes/object_type.go index 9136a59b..549570d9 100644 --- a/types/basetypes/object_type.go +++ b/types/basetypes/object_type.go @@ -12,6 +12,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-go/tftypes" + tfrefinement "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" ) var _ ObjectTypable = ObjectType{} @@ -76,12 +77,34 @@ func (o ObjectType) ValueFromTerraform(ctx context.Context, in tftypes.Value) (a return nil, fmt.Errorf("expected %s, got %s", o.TerraformType(ctx), in.Type()) } if !in.IsKnown() { - return NewObjectUnknown(o.AttrTypes), nil + unknownVal := NewObjectUnknown(o.AttrTypes) + refinements := in.Refinements() + + if len(refinements) == 0 { + return unknownVal, nil + } + + for _, refn := range refinements { + switch refnVal := refn.(type) { + case tfrefinement.Nullness: + if !refnVal.Nullness() { + unknownVal = unknownVal.RefineAsNotNull() + } else { + // This scenario shouldn't occur, as Terraform should have already collapsed an + // unknown value with a definitely null refinement into a known null value. However, + // the protocol encoding does support this refinement value, so we'll also just collapse + // it into a known null value here. + return NewObjectNull(o.AttrTypes), nil + } + } + } + + return unknownVal, nil } + if in.IsNull() { return NewObjectNull(o.AttrTypes), nil } - attributes := map[string]attr.Value{} val := map[string]tftypes.Value{} err := in.As(&val) @@ -89,6 +112,7 @@ func (o ObjectType) ValueFromTerraform(ctx context.Context, in tftypes.Value) (a return nil, err } + attributes := map[string]attr.Value{} for k, v := range val { a, err := o.AttrTypes[k].ValueFromTerraform(ctx, v) if err != nil { diff --git a/types/basetypes/object_type_test.go b/types/basetypes/object_type_test.go index 59d1ea83..9ebbc3da 100644 --- a/types/basetypes/object_type_test.go +++ b/types/basetypes/object_type_test.go @@ -11,6 +11,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-go/tftypes" + tfrefinement "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" ) func TestObjectTypeAttributeTypes_immutable(t *testing.T) { @@ -200,6 +201,21 @@ func TestObjectTypeValueFromTerraform(t *testing.T) { }, ), }, + "unknown-with-notnull-refinement": { + receiver: ObjectType{ + AttrTypes: map[string]attr.Type{ + "a": StringType{}, + }, + }, + input: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "a": tftypes.String, + }, + }, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + }), + expected: NewObjectUnknown(map[string]attr.Type{"a": StringType{}}).RefineAsNotNull(), + }, "null": { receiver: ObjectType{ AttrTypes: map[string]attr.Type{ @@ -445,3 +461,29 @@ func TestObjectTypeString(t *testing.T) { }) } } + +func TestObjectTypeValueFromTerraform_RefinementNullCollapse(t *testing.T) { + t.Parallel() + + receiver := ObjectType{ + AttrTypes: map[string]attr.Type{ + "a": StringType{}, + }, + } + + // This shouldn't happen, but this test ensures that if we receive this kind of refinement, that we will + // convert it to a known null value. + input := tftypes.NewValue(receiver.TerraformType(context.Background()), tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(true), + }) + expectation := NewObjectNull(receiver.AttributeTypes()) + + got, err := receiver.ValueFromTerraform(context.Background(), input) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + if !got.Equal(expectation) { + t.Errorf("Expected %+v, got %+v", expectation, got) + } +} diff --git a/types/basetypes/object_value.go b/types/basetypes/object_value.go index baeb8c0e..e758f38c 100644 --- a/types/basetypes/object_value.go +++ b/types/basetypes/object_value.go @@ -13,11 +13,16 @@ import ( "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/internal/reflect" "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/types/refinement" "github.com/hashicorp/terraform-plugin-go/tftypes" + tfrefinement "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" ) -var _ ObjectValuable = &ObjectValue{} +var ( + _ ObjectValuable = &ObjectValue{} + _ attr.ValueWithNotNullRefinement = &ObjectValue{} +) // ObjectValuable extends attr.Value for object value types. // Implement this interface to create a custom Object value type. @@ -191,6 +196,10 @@ type ObjectValue struct { // state represents whether the value is null, unknown, or known. The // zero-value is null. state attr.ValueState + + // refinements represents the unknown value refinement data associated with this Value. + // This field is only populated for unknown values. + refinements refinement.Refinements } // ObjectAsOptions is a collection of toggles to control the behavior of @@ -292,7 +301,20 @@ func (o ObjectValue) ToTerraformValue(ctx context.Context) (tftypes.Value, error case attr.ValueStateNull: return tftypes.NewValue(objectType, nil), nil case attr.ValueStateUnknown: - return tftypes.NewValue(objectType, tftypes.UnknownValue), nil + if len(o.refinements) == 0 { + return tftypes.NewValue(objectType, tftypes.UnknownValue), nil + } + + unknownValRefinements := make(tfrefinement.Refinements, 0) + for _, refn := range o.refinements { + switch refn.(type) { + case refinement.NotNull: + unknownValRefinements[tfrefinement.KeyNullness] = tfrefinement.NewNullness(false) + } + } + unknownVal := tftypes.NewValue(objectType, tftypes.UnknownValue) + + return unknownVal.Refine(unknownValRefinements), nil default: panic(fmt.Sprintf("unhandled Object state in ToTerraformValue: %s", o.state)) } @@ -312,6 +334,14 @@ func (o ObjectValue) Equal(c attr.Value) bool { return false } + if len(o.refinements) != len(other.refinements) { + return false + } + + if len(o.refinements) > 0 && !o.refinements.Equal(other.refinements) { + return false + } + if o.state != attr.ValueStateKnown { return true } @@ -366,7 +396,11 @@ func (o ObjectValue) IsUnknown() bool { // and is intended for logging and error reporting. func (o ObjectValue) String() string { if o.IsUnknown() { - return attr.UnknownValueString + if len(o.refinements) == 0 { + return attr.UnknownValueString + } + + return fmt.Sprintf("", o.refinements.String()) } if o.IsNull() { @@ -398,3 +432,48 @@ func (o ObjectValue) String() string { func (o ObjectValue) ToObjectValue(context.Context) (ObjectValue, diag.Diagnostics) { return o, nil } + +// RefineAsNotNull will return a new unknown ObjectValue that includes a value refinement that: +// - Indicates the object value will not be null once it becomes known. +// +// If the provided ObjectValue is null or known, then the ObjectValue will be returned unchanged. +func (o ObjectValue) RefineAsNotNull() ObjectValue { + if !o.IsUnknown() { + return o + } + + newRefinements := make(refinement.Refinements, len(o.refinements)) + for i, refn := range o.refinements { + newRefinements[i] = refn + } + + newRefinements[refinement.KeyNotNull] = refinement.NewNotNull() + + newUnknownVal := NewObjectUnknown(o.AttributeTypes(context.Background())) + newUnknownVal.refinements = newRefinements + + return newUnknownVal +} + +// NotNullRefinement returns value refinement data and a boolean indicating if a NotNull refinement +// exists on the given ObjectValue. If a ObjectValue contains a NotNull refinement, this indicates +// that the object is unknown, but the eventual known value will not be null. +// +// A NotNull value refinement can be added to an unknown value via the `RefineAsNotNull` method. +func (o ObjectValue) NotNullRefinement() (*refinement.NotNull, bool) { + if !o.IsUnknown() { + return nil, false + } + + refn, ok := o.refinements[refinement.KeyNotNull] + if !ok { + return nil, false + } + + notNullRefn, ok := refn.(refinement.NotNull) + if !ok { + return nil, false + } + + return ¬NullRefn, true +} diff --git a/types/basetypes/object_value_test.go b/types/basetypes/object_value_test.go index 73440f29..8cd2e532 100644 --- a/types/basetypes/object_value_test.go +++ b/types/basetypes/object_value_test.go @@ -13,7 +13,9 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/types/refinement" "github.com/hashicorp/terraform-plugin-go/tftypes" + tfrefinement "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" ) func BenchmarkObjectValueToTerraformValue1000(b *testing.B) { @@ -825,6 +827,40 @@ func TestObjectValueToTerraformValue(t *testing.T) { }, }, tftypes.UnknownValue), }, + "unknown-with-notnull-refinement": { + receiver: NewObjectUnknown( + map[string]attr.Type{ + "a": ListType{ElemType: StringType{}}, + "b": StringType{}, + "c": BoolType{}, + "d": NumberType{}, + "e": ObjectType{ + AttrTypes: map[string]attr.Type{ + "name": StringType{}, + }, + }, + "f": SetType{ElemType: StringType{}}, + "g": DynamicType{}, + }, + ).RefineAsNotNull(), + expected: tftypes.NewValue(tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "a": tftypes.List{ElementType: tftypes.String}, + "b": tftypes.String, + "c": tftypes.Bool, + "d": tftypes.Number, + "e": tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "name": tftypes.String, + }, + }, + "f": tftypes.Set{ElementType: tftypes.String}, + "g": tftypes.DynamicPseudoType, + }, + }, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + }), + }, "null": { receiver: NewObjectNull( map[string]attr.Type{ @@ -1462,6 +1498,37 @@ func TestObjectValueEqual(t *testing.T) { ), expected: true, }, + "unknown-unknown-with-notnull-refinement": { + receiver: NewObjectUnknown( + map[string]attr.Type{ + "string": StringType{}, + "bool": BoolType{}, + "number": NumberType{}, + }, + ), + arg: NewObjectUnknown( + map[string]attr.Type{ + "string": StringType{}, + "bool": BoolType{}, + "number": NumberType{}, + }).RefineAsNotNull(), + expected: false, + }, + "unknowns-with-matching-notnull-refinements": { + receiver: NewObjectUnknown( + map[string]attr.Type{ + "string": StringType{}, + "bool": BoolType{}, + "number": NumberType{}, + }).RefineAsNotNull(), + arg: NewObjectUnknown( + map[string]attr.Type{ + "string": StringType{}, + "bool": BoolType{}, + "number": NumberType{}, + }).RefineAsNotNull(), + expected: true, + }, "unknown-null": { receiver: NewObjectUnknown( map[string]attr.Type{ @@ -1712,6 +1779,10 @@ func TestObjectValueString(t *testing.T) { input: NewObjectUnknown(map[string]attr.Type{"test_attr": StringType{}}), expectation: "", }, + "unknown-with-notnull-refinement": { + input: NewObjectUnknown(map[string]attr.Type{"test_attr": StringType{}}).RefineAsNotNull(), + expectation: "", + }, "null": { input: NewObjectNull(map[string]attr.Type{"test_attr": StringType{}}), expectation: "", @@ -1839,3 +1910,62 @@ func TestObjectValueType(t *testing.T) { }) } } + +func TestObjectValue_NotNullRefinement(t *testing.T) { + t.Parallel() + + type testCase struct { + input ObjectValue + expectedRefnVal refinement.Refinement + expectedFound bool + } + tests := map[string]testCase{ + "known-ignored": { + input: NewObjectValueMust( + map[string]attr.Type{ + "test_attr": StringType{}, + }, + map[string]attr.Value{ + "test_attr": NewStringValue("hello"), + }).RefineAsNotNull(), + expectedFound: false, + }, + "null-ignored": { + input: NewObjectNull(map[string]attr.Type{"test_attr": StringType{}}).RefineAsNotNull(), + expectedFound: false, + }, + "unknown-no-refinement": { + input: NewObjectUnknown(map[string]attr.Type{"test_attr": StringType{}}), + expectedFound: false, + }, + "unknown-with-notnull-refinement": { + input: NewObjectUnknown(map[string]attr.Type{"test_attr": StringType{}}).RefineAsNotNull(), + expectedRefnVal: refinement.NewNotNull(), + expectedFound: true, + }, + } + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, found := test.input.NotNullRefinement() + if found != test.expectedFound { + t.Fatalf("Expected refinement exists to be: %t, got: %t", test.expectedFound, found) + } + + if got == nil && test.expectedRefnVal == nil { + // Success! + return + } + + if got == nil && test.expectedRefnVal != nil { + t.Fatalf("Expected refinement data: <%+v>, got: nil", test.expectedRefnVal) + } + + if diff := cmp.Diff(*got, test.expectedRefnVal); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/types/basetypes/set_type.go b/types/basetypes/set_type.go index d6b033a8..1277de22 100644 --- a/types/basetypes/set_type.go +++ b/types/basetypes/set_type.go @@ -8,6 +8,7 @@ import ( "fmt" "github.com/hashicorp/terraform-plugin-go/tftypes" + tfrefinement "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/attr/xattr" @@ -88,7 +89,33 @@ func (st SetType) ValueFromTerraform(ctx context.Context, in tftypes.Value) (att return nil, fmt.Errorf("can't use %s as value of Set with ElementType %T, can only use %s values", in.String(), st.ElementType(), st.ElementType().TerraformType(ctx).String()) } if !in.IsKnown() { - return NewSetUnknown(st.ElementType()), nil + unknownVal := NewSetUnknown(st.ElementType()) + refinements := in.Refinements() + + if len(refinements) == 0 { + return unknownVal, nil + } + + for _, refn := range refinements { + switch refnVal := refn.(type) { + case tfrefinement.Nullness: + if !refnVal.Nullness() { + unknownVal = unknownVal.RefineAsNotNull() + } else { + // This scenario shouldn't occur, as Terraform should have already collapsed an + // unknown value with a definitely null refinement into a known null value. However, + // the protocol encoding does support this refinement value, so we'll also just collapse + // it into a known null value here. + return NewSetNull(st.ElementType()), nil + } + case tfrefinement.CollectionLengthLowerBound: + unknownVal = unknownVal.RefineWithLengthLowerBound(refnVal.LowerBound()) + case tfrefinement.CollectionLengthUpperBound: + unknownVal = unknownVal.RefineWithLengthUpperBound(refnVal.UpperBound()) + } + } + + return unknownVal, nil } if in.IsNull() { return NewSetNull(st.ElementType()), nil diff --git a/types/basetypes/set_type_test.go b/types/basetypes/set_type_test.go index 4e645aa1..a63c8502 100644 --- a/types/basetypes/set_type_test.go +++ b/types/basetypes/set_type_test.go @@ -10,6 +10,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-go/tftypes" + tfrefinement "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" ) func TestSetTypeElementType(t *testing.T) { @@ -165,6 +166,46 @@ func TestSetTypeValueFromTerraform(t *testing.T) { }, tftypes.UnknownValue), expected: NewSetUnknown(StringType{}), }, + "unknown-with-notnull-refinement": { + receiver: SetType{ + ElemType: StringType{}, + }, + input: tftypes.NewValue(tftypes.Set{ElementType: tftypes.String}, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + }), + expected: NewSetUnknown(StringType{}).RefineAsNotNull(), + }, + "unknown-with-length-lowerbound-refinement": { + receiver: SetType{ + ElemType: StringType{}, + }, + input: tftypes.NewValue(tftypes.Set{ElementType: tftypes.String}, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + tfrefinement.KeyCollectionLengthLowerBound: tfrefinement.NewCollectionLengthLowerBound(5), + }), + expected: NewSetUnknown(StringType{}).RefineWithLengthLowerBound(5), + }, + "unknown-with-length-upperbound-refinement": { + receiver: SetType{ + ElemType: StringType{}, + }, + input: tftypes.NewValue(tftypes.Set{ElementType: tftypes.String}, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + tfrefinement.KeyCollectionLengthUpperBound: tfrefinement.NewCollectionLengthUpperBound(10), + }), + expected: NewSetUnknown(StringType{}).RefineWithLengthUpperBound(10), + }, + "unknown-with-both-length-bound-refinements": { + receiver: SetType{ + ElemType: StringType{}, + }, + input: tftypes.NewValue(tftypes.Set{ElementType: tftypes.String}, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + tfrefinement.KeyCollectionLengthLowerBound: tfrefinement.NewCollectionLengthLowerBound(5), + tfrefinement.KeyCollectionLengthUpperBound: tfrefinement.NewCollectionLengthUpperBound(10), + }), + expected: NewSetUnknown(StringType{}).RefineWithLengthLowerBound(5).RefineWithLengthUpperBound(10), + }, "partially-unknown-set": { receiver: SetType{ ElemType: StringType{}, @@ -362,3 +403,23 @@ func TestSetTypeString(t *testing.T) { }) } } + +func TestSetTypeValueFromTerraform_RefinementNullCollapse(t *testing.T) { + t.Parallel() + + // This shouldn't happen, but this test ensures that if we receive this kind of refinement, that we will + // convert it to a known null value. + input := tftypes.NewValue(tftypes.Set{ElementType: tftypes.String}, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(true), + }) + expectation := NewSetNull(StringType{}) + + got, err := SetType{ElemType: StringType{}}.ValueFromTerraform(context.Background(), input) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + if !got.Equal(expectation) { + t.Errorf("Expected %+v, got %+v", expectation, got) + } +} diff --git a/types/basetypes/set_value.go b/types/basetypes/set_value.go index 2064e8fb..791db089 100644 --- a/types/basetypes/set_value.go +++ b/types/basetypes/set_value.go @@ -14,9 +14,14 @@ import ( "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/internal/reflect" "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/types/refinement" + tfrefinement "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" ) -var _ SetValuable = &SetValue{} +var ( + _ SetValuable = &SetValue{} + _ attr.ValueWithNotNullRefinement = &SetValue{} +) // SetValuable extends attr.Value for set value types. // Implement this interface to create a custom Set value type. @@ -162,6 +167,10 @@ type SetValue struct { // state represents whether the value is null, unknown, or known. The // zero-value is null. state attr.ValueState + + // refinements represents the unknown value refinement data associated with this Value. + // This field is only populated for unknown values. + refinements refinement.Refinements } // Elements returns a copy of the collection of elements for the Set. @@ -242,7 +251,24 @@ func (s SetValue) ToTerraformValue(ctx context.Context) (tftypes.Value, error) { case attr.ValueStateNull: return tftypes.NewValue(setType, nil), nil case attr.ValueStateUnknown: - return tftypes.NewValue(setType, tftypes.UnknownValue), nil + if len(s.refinements) == 0 { + return tftypes.NewValue(setType, tftypes.UnknownValue), nil + } + + unknownValRefinements := make(tfrefinement.Refinements, 0) + for _, refn := range s.refinements { + switch refnVal := refn.(type) { + case refinement.NotNull: + unknownValRefinements[tfrefinement.KeyNullness] = tfrefinement.NewNullness(false) + case refinement.CollectionLengthLowerBound: + unknownValRefinements[tfrefinement.KeyCollectionLengthLowerBound] = tfrefinement.NewCollectionLengthLowerBound(refnVal.LowerBound()) + case refinement.CollectionLengthUpperBound: + unknownValRefinements[tfrefinement.KeyCollectionLengthUpperBound] = tfrefinement.NewCollectionLengthUpperBound(refnVal.UpperBound()) + } + } + unknownVal := tftypes.NewValue(setType, tftypes.UnknownValue) + + return unknownVal.Refine(unknownValRefinements), nil default: panic(fmt.Sprintf("unhandled Set state in ToTerraformValue: %s", s.state)) } @@ -271,6 +297,14 @@ func (s SetValue) Equal(o attr.Value) bool { return false } + if len(s.refinements) != len(other.refinements) { + return false + } + + if len(s.refinements) > 0 && !s.refinements.Equal(other.refinements) { + return false + } + if s.state != attr.ValueStateKnown { return true } @@ -315,7 +349,11 @@ func (s SetValue) IsUnknown() bool { // and is intended for logging and error reporting. func (s SetValue) String() string { if s.IsUnknown() { - return attr.UnknownValueString + if len(s.refinements) == 0 { + return attr.UnknownValueString + } + + return fmt.Sprintf("", s.refinements.String()) } if s.IsNull() { @@ -340,3 +378,144 @@ func (s SetValue) String() string { func (s SetValue) ToSetValue(context.Context) (SetValue, diag.Diagnostics) { return s, nil } + +// RefineAsNotNull will return a new unknown SetValue that includes a value refinement that: +// - Indicates the set value will not be null once it becomes known. +// +// If the provided SetValue is null or known, then the SetValue will be returned unchanged. +func (s SetValue) RefineAsNotNull() SetValue { + if !s.IsUnknown() { + return s + } + + newRefinements := make(refinement.Refinements, len(s.refinements)) + for i, refn := range s.refinements { + newRefinements[i] = refn + } + + newRefinements[refinement.KeyNotNull] = refinement.NewNotNull() + + newUnknownVal := NewSetUnknown(s.ElementType(context.Background())) + newUnknownVal.refinements = newRefinements + + return newUnknownVal +} + +// RefineWithLengthLowerBound will return an unknown SetValue that includes a value refinement that: +// - Indicates the set value will not be null once it becomes known. +// - Indicates the length of the set value will be at least the int64 provided (lowerBound) once it becomes known. +// +// If the provided SetValue is null or known, then the SetValue will be returned unchanged. +func (s SetValue) RefineWithLengthLowerBound(lowerBound int64) SetValue { + if !s.IsUnknown() { + return s + } + + newRefinements := make(refinement.Refinements, len(s.refinements)) + for i, refn := range s.refinements { + newRefinements[i] = refn + } + + newRefinements[refinement.KeyNotNull] = refinement.NewNotNull() + newRefinements[refinement.KeyCollectionLengthLowerBound] = refinement.NewCollectionLengthLowerBound(lowerBound) + + newUnknownVal := NewSetUnknown(s.ElementType(context.Background())) + newUnknownVal.refinements = newRefinements + + return newUnknownVal +} + +// RefineWithLengthUpperBound will return an unknown SetValue that includes a value refinement that: +// - Indicates the set value will not be null once it becomes known. +// - Indicates the length of the set value will be at most the int64 provided (upperBound) once it becomes known. +// +// If the provided SetValue is null or known, then the SetValue will be returned unchanged. +func (s SetValue) RefineWithLengthUpperBound(upperBound int64) SetValue { + if !s.IsUnknown() { + return s + } + + newRefinements := make(refinement.Refinements, len(s.refinements)) + for i, refn := range s.refinements { + newRefinements[i] = refn + } + + newRefinements[refinement.KeyNotNull] = refinement.NewNotNull() + newRefinements[refinement.KeyCollectionLengthUpperBound] = refinement.NewCollectionLengthUpperBound(upperBound) + + newUnknownVal := NewSetUnknown(s.ElementType(context.Background())) + newUnknownVal.refinements = newRefinements + + return newUnknownVal +} + +// NotNullRefinement returns value refinement data and a boolean indicating if a NotNull refinement +// exists on the given SetValue. If a SetValue contains a NotNull refinement, this indicates +// that the set is unknown, but the eventual known value will not be null. +// +// A NotNull value refinement can be added to an unknown value via the `RefineAsNotNull` method. +func (s SetValue) NotNullRefinement() (*refinement.NotNull, bool) { + if !s.IsUnknown() { + return nil, false + } + + refn, ok := s.refinements[refinement.KeyNotNull] + if !ok { + return nil, false + } + + notNullRefn, ok := refn.(refinement.NotNull) + if !ok { + return nil, false + } + + return ¬NullRefn, true +} + +// LengthLowerBoundRefinement returns value refinement data and a boolean indicating if a CollectionLengthLowerBound refinement +// exists on the given SetValue. If a SetValue contains a CollectionLengthLowerBound refinement, this indicates that +// the set value is unknown, but the eventual known set will have a length of at least the specified int64 value once it +// becomes known. The returned boolean should be checked before accessing refinement data. +// +// A CollectionLengthLowerBound value refinement can be added to an unknown value via the `RefineWithLengthLowerBound` method. +func (s SetValue) LengthLowerBoundRefinement() (*refinement.CollectionLengthLowerBound, bool) { + if !s.IsUnknown() { + return nil, false + } + + refn, ok := s.refinements[refinement.KeyCollectionLengthLowerBound] + if !ok { + return nil, false + } + + lowerBoundRefn, ok := refn.(refinement.CollectionLengthLowerBound) + if !ok { + return nil, false + } + + return &lowerBoundRefn, true +} + +// LengthUpperBoundRefinement returns value refinement data and a boolean indicating if a CollectionLengthUpperBound refinement +// exists on the given SetValue. If a SetValue contains a CollectionLengthUpperBound refinement, this indicates that +// the set value is unknown, but the eventual known set will have a length at most the specified int64 value once it +// becomes known. The returned boolean should be checked before accessing refinement data. +// +// A CollectionLengthUpperBound value refinement can be added to an unknown value via the `RefineWithLengthUpperBound` method. +func (s SetValue) LengthUpperBoundRefinement() (*refinement.CollectionLengthUpperBound, bool) { + if !s.IsUnknown() { + return nil, false + } + + refn, ok := s.refinements[refinement.KeyCollectionLengthUpperBound] + if !ok { + return nil, false + } + + upperBoundRefn, ok := refn.(refinement.CollectionLengthUpperBound) + if !ok { + return nil, false + } + + return &upperBoundRefn, true +} diff --git a/types/basetypes/set_value_test.go b/types/basetypes/set_value_test.go index 260382d0..87dccfd3 100644 --- a/types/basetypes/set_value_test.go +++ b/types/basetypes/set_value_test.go @@ -10,10 +10,12 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform-plugin-go/tftypes" + tfrefinement "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/types/refinement" ) func TestSetElementsAs_stringSlice(t *testing.T) { @@ -552,6 +554,34 @@ func TestSetValueToTerraformValue(t *testing.T) { input: NewSetUnknown(StringType{}), expectation: tftypes.NewValue(tftypes.Set{ElementType: tftypes.String}, tftypes.UnknownValue), }, + "unknown-with-notnull-refinement": { + input: NewSetUnknown(StringType{}).RefineAsNotNull(), + expectation: tftypes.NewValue(tftypes.Set{ElementType: tftypes.String}, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + }), + }, + "unknown-with-length-lower-bound-refinement": { + input: NewSetUnknown(StringType{}).RefineWithLengthLowerBound(5), + expectation: tftypes.NewValue(tftypes.Set{ElementType: tftypes.String}, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + tfrefinement.KeyCollectionLengthLowerBound: tfrefinement.NewCollectionLengthLowerBound(5), + }), + }, + "unknown-with-length-upper-bound-refinement": { + input: NewSetUnknown(StringType{}).RefineWithLengthUpperBound(10), + expectation: tftypes.NewValue(tftypes.Set{ElementType: tftypes.String}, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + tfrefinement.KeyCollectionLengthUpperBound: tfrefinement.NewCollectionLengthUpperBound(10), + }), + }, + "unknown-with-both-length-bound-refinements": { + input: NewSetUnknown(StringType{}).RefineWithLengthLowerBound(5).RefineWithLengthUpperBound(10), + expectation: tftypes.NewValue(tftypes.Set{ElementType: tftypes.String}, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + tfrefinement.KeyCollectionLengthLowerBound: tfrefinement.NewCollectionLengthLowerBound(5), + tfrefinement.KeyCollectionLengthUpperBound: tfrefinement.NewCollectionLengthUpperBound(10), + }), + }, "null": { input: NewSetNull(StringType{}), expectation: tftypes.NewValue(tftypes.Set{ElementType: tftypes.String}, nil), @@ -886,6 +916,56 @@ func TestSetValueEqual(t *testing.T) { input: SetValue{}, expected: false, }, + "unknown-unknown-with-notnull-refinement": { + receiver: NewSetUnknown(StringType{}), + input: NewSetUnknown(StringType{}).RefineAsNotNull(), + expected: false, + }, + "unknown-unknown-with-length-lowerbound-refinement": { + receiver: NewSetUnknown(StringType{}), + input: NewSetUnknown(StringType{}).RefineWithLengthLowerBound(5), + expected: false, + }, + "unknown-unknown-with-length-upperbound-refinement": { + receiver: NewSetUnknown(StringType{}), + input: NewSetUnknown(StringType{}).RefineWithLengthUpperBound(10), + expected: false, + }, + "unknowns-with-matching-notnull-refinements": { + receiver: NewSetUnknown(StringType{}).RefineAsNotNull(), + input: NewSetUnknown(StringType{}).RefineAsNotNull(), + expected: true, + }, + "unknowns-with-matching-length-lowerbound-refinements": { + receiver: NewSetUnknown(StringType{}).RefineWithLengthLowerBound(5), + input: NewSetUnknown(StringType{}).RefineWithLengthLowerBound(5), + expected: true, + }, + "unknowns-with-different-length-lowerbound-refinements": { + receiver: NewSetUnknown(StringType{}).RefineWithLengthLowerBound(5), + input: NewSetUnknown(StringType{}).RefineWithLengthLowerBound(6), + expected: false, + }, + "unknowns-with-matching-length-upperbound-refinements": { + receiver: NewSetUnknown(StringType{}).RefineWithLengthUpperBound(10), + input: NewSetUnknown(StringType{}).RefineWithLengthUpperBound(10), + expected: true, + }, + "unknowns-with-different-length-upperbound-refinements": { + receiver: NewSetUnknown(StringType{}).RefineWithLengthUpperBound(10), + input: NewSetUnknown(StringType{}).RefineWithLengthUpperBound(11), + expected: false, + }, + "unknowns-with-matching-both-length-bound-refinements": { + receiver: NewSetUnknown(StringType{}).RefineWithLengthLowerBound(5).RefineWithLengthUpperBound(10), + input: NewSetUnknown(StringType{}).RefineWithLengthLowerBound(5).RefineWithLengthUpperBound(10), + expected: true, + }, + "unknowns-with-different-both-length-bound-refinements": { + receiver: NewSetUnknown(StringType{}).RefineWithLengthLowerBound(5).RefineWithLengthUpperBound(10), + input: NewSetUnknown(StringType{}).RefineWithLengthLowerBound(5).RefineWithLengthUpperBound(11), + expected: false, + }, } for name, test := range tests { name, test := name, test @@ -1018,6 +1098,22 @@ func TestSetValueString(t *testing.T) { input: NewSetUnknown(StringType{}), expectation: "", }, + "unknown-with-notnull-refinement": { + input: NewSetUnknown(StringType{}).RefineAsNotNull(), + expectation: "", + }, + "unknown-with-length-lowerbound-refinement": { + input: NewSetUnknown(StringType{}).RefineWithLengthLowerBound(5), + expectation: ``, + }, + "unknown-with-length-upperbound-refinement": { + input: NewSetUnknown(StringType{}).RefineWithLengthUpperBound(10), + expectation: ``, + }, + "unknown-with-both-length-bound-refinements": { + input: NewSetUnknown(StringType{}).RefineWithLengthLowerBound(5).RefineWithLengthUpperBound(10), + expectation: ``, + }, "null": { input: NewSetNull(StringType{}), expectation: "", @@ -1109,3 +1205,162 @@ func TestSetValueType(t *testing.T) { }) } } + +func TestSetValue_NotNullRefinement(t *testing.T) { + t.Parallel() + + type testCase struct { + input SetValue + expectedRefnVal refinement.Refinement + expectedFound bool + } + tests := map[string]testCase{ + "known-ignored": { + input: NewSetValueMust(StringType{}, []attr.Value{NewStringValue("hello")}).RefineAsNotNull(), + expectedFound: false, + }, + "null-ignored": { + input: NewSetNull(StringType{}).RefineAsNotNull(), + expectedFound: false, + }, + "unknown-no-refinement": { + input: NewSetUnknown(StringType{}), + expectedFound: false, + }, + "unknown-with-notnull-refinement": { + input: NewSetUnknown(StringType{}).RefineAsNotNull(), + expectedRefnVal: refinement.NewNotNull(), + expectedFound: true, + }, + } + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, found := test.input.NotNullRefinement() + if found != test.expectedFound { + t.Fatalf("Expected refinement exists to be: %t, got: %t", test.expectedFound, found) + } + + if got == nil && test.expectedRefnVal == nil { + // Success! + return + } + + if got == nil && test.expectedRefnVal != nil { + t.Fatalf("Expected refinement data: <%+v>, got: nil", test.expectedRefnVal) + } + + if diff := cmp.Diff(*got, test.expectedRefnVal); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestSetValue_LengthLowerBoundRefinement(t *testing.T) { + t.Parallel() + + type testCase struct { + input SetValue + expectedRefnVal refinement.Refinement + expectedFound bool + } + tests := map[string]testCase{ + "known-ignored": { + input: NewSetValueMust(StringType{}, []attr.Value{NewStringValue("hello")}).RefineWithLengthLowerBound(5), + expectedFound: false, + }, + "null-ignored": { + input: NewSetNull(StringType{}).RefineWithLengthLowerBound(5), + expectedFound: false, + }, + "unknown-no-refinement": { + input: NewSetUnknown(StringType{}), + expectedFound: false, + }, + "unknown-with-length-lowerbound-refinement": { + input: NewSetUnknown(StringType{}).RefineWithLengthLowerBound(5), + expectedRefnVal: refinement.NewCollectionLengthLowerBound(5), + expectedFound: true, + }, + } + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, found := test.input.LengthLowerBoundRefinement() + if found != test.expectedFound { + t.Fatalf("Expected refinement exists to be: %t, got: %t", test.expectedFound, found) + } + + if got == nil && test.expectedRefnVal == nil { + // Success! + return + } + + if got == nil && test.expectedRefnVal != nil { + t.Fatalf("Expected refinement data: <%+v>, got: nil", test.expectedRefnVal) + } + + if diff := cmp.Diff(*got, test.expectedRefnVal); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestSetValue_LengthUpperBoundRefinement(t *testing.T) { + t.Parallel() + + type testCase struct { + input SetValue + expectedRefnVal refinement.Refinement + expectedFound bool + } + tests := map[string]testCase{ + "known-ignored": { + input: NewSetValueMust(StringType{}, []attr.Value{NewStringValue("hello")}).RefineWithLengthUpperBound(10), + expectedFound: false, + }, + "null-ignored": { + input: NewSetNull(StringType{}).RefineWithLengthUpperBound(10), + expectedFound: false, + }, + "unknown-no-refinement": { + input: NewSetUnknown(StringType{}), + expectedFound: false, + }, + "unknown-with-length-upperbound-refinement": { + input: NewSetUnknown(StringType{}).RefineWithLengthUpperBound(10), + expectedRefnVal: refinement.NewCollectionLengthUpperBound(10), + expectedFound: true, + }, + } + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, found := test.input.LengthUpperBoundRefinement() + if found != test.expectedFound { + t.Fatalf("Expected refinement exists to be: %t, got: %t", test.expectedFound, found) + } + + if got == nil && test.expectedRefnVal == nil { + // Success! + return + } + + if got == nil && test.expectedRefnVal != nil { + t.Fatalf("Expected refinement data: <%+v>, got: nil", test.expectedRefnVal) + } + + if diff := cmp.Diff(*got, test.expectedRefnVal); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/types/basetypes/string_type.go b/types/basetypes/string_type.go index 319ae02b..7102dd65 100644 --- a/types/basetypes/string_type.go +++ b/types/basetypes/string_type.go @@ -10,6 +10,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-go/tftypes" + tfrefinement "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" ) // StringTypable extends attr.Type for string types. @@ -61,7 +62,31 @@ func (t StringType) ValueFromString(_ context.Context, v StringValue) (StringVal // consume the data with. func (t StringType) ValueFromTerraform(ctx context.Context, in tftypes.Value) (attr.Value, error) { if !in.IsKnown() { - return NewStringUnknown(), nil + unknownVal := NewStringUnknown() + refinements := in.Refinements() + + if len(refinements) == 0 { + return unknownVal, nil + } + + for _, refn := range refinements { + switch refnVal := refn.(type) { + case tfrefinement.Nullness: + if !refnVal.Nullness() { + unknownVal = unknownVal.RefineAsNotNull() + } else { + // This scenario shouldn't occur, as Terraform should have already collapsed an + // unknown value with a definitely null refinement into a known null value. However, + // the protocol encoding does support this refinement value, so we'll also just collapse + // it into a known null value here. + return NewStringNull(), nil + } + case tfrefinement.StringPrefix: + unknownVal = unknownVal.RefineWithPrefix(refnVal.PrefixValue()) + } + } + + return unknownVal, nil } if in.IsNull() { diff --git a/types/basetypes/string_type_test.go b/types/basetypes/string_type_test.go index 097bb89c..213dae3c 100644 --- a/types/basetypes/string_type_test.go +++ b/types/basetypes/string_type_test.go @@ -9,6 +9,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-go/tftypes" + tfrefinement "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" ) func TestStringTypeValueFromTerraform(t *testing.T) { @@ -28,6 +29,19 @@ func TestStringTypeValueFromTerraform(t *testing.T) { input: tftypes.NewValue(tftypes.String, tftypes.UnknownValue), expectation: NewStringUnknown(), }, + "unknown-with-notnull-refinement": { + input: tftypes.NewValue(tftypes.String, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + }), + expectation: NewStringUnknown().RefineAsNotNull(), + }, + "unknown-with-prefix-refinement": { + input: tftypes.NewValue(tftypes.String, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + tfrefinement.KeyStringPrefix: tfrefinement.NewStringPrefix("hello://"), + }), + expectation: NewStringUnknown().RefineWithPrefix("hello://"), + }, "null": { input: tftypes.NewValue(tftypes.String, nil), expectation: NewStringNull(), @@ -57,7 +71,7 @@ func TestStringTypeValueFromTerraform(t *testing.T) { // expectations, we're good return } - if err == nil && test.expectedErr != "" { + if test.expectedErr != "" { t.Errorf("Expected error to be %q, didn't get an error", test.expectedErr) return } @@ -73,3 +87,23 @@ func TestStringTypeValueFromTerraform(t *testing.T) { }) } } + +func TestStringTypeValueFromTerraform_RefinementNullCollapse(t *testing.T) { + t.Parallel() + + // This shouldn't happen, but this test ensures that if we receive this kind of refinement, that we will + // convert it to a known null value. + input := tftypes.NewValue(tftypes.String, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(true), + }) + expectation := NewStringNull() + + got, err := StringType{}.ValueFromTerraform(context.Background(), input) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + if !got.Equal(expectation) { + t.Errorf("Expected %+v, got %+v", expectation, got) + } +} diff --git a/types/basetypes/string_value.go b/types/basetypes/string_value.go index 46ac2248..897af903 100644 --- a/types/basetypes/string_value.go +++ b/types/basetypes/string_value.go @@ -11,10 +11,13 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types/refinement" + tfrefinement "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" ) var ( - _ StringValuable = StringValue{} + _ StringValuable = StringValue{} + _ attr.ValueWithNotNullRefinement = StringValue{} ) // StringValuable extends attr.Value for string value types. @@ -94,6 +97,10 @@ type StringValue struct { // value contains the known value, if not null or unknown. value string + + // refinements represents the unknown value refinement data associated with this Value. + // This field is only populated for unknown values. + refinements refinement.Refinements } // Type returns a StringType. @@ -113,7 +120,22 @@ func (s StringValue) ToTerraformValue(_ context.Context) (tftypes.Value, error) case attr.ValueStateNull: return tftypes.NewValue(tftypes.String, nil), nil case attr.ValueStateUnknown: - return tftypes.NewValue(tftypes.String, tftypes.UnknownValue), nil + if len(s.refinements) == 0 { + return tftypes.NewValue(tftypes.String, tftypes.UnknownValue), nil + } + + unknownValRefinements := make(tfrefinement.Refinements, 0) + for _, refn := range s.refinements { + switch refnVal := refn.(type) { + case refinement.NotNull: + unknownValRefinements[tfrefinement.KeyNullness] = tfrefinement.NewNullness(false) + case refinement.StringPrefix: + unknownValRefinements[tfrefinement.KeyStringPrefix] = tfrefinement.NewStringPrefix(refnVal.PrefixValue()) + } + } + unknownVal := tftypes.NewValue(tftypes.String, tftypes.UnknownValue) + + return unknownVal.Refine(unknownValRefinements), nil default: panic(fmt.Sprintf("unhandled String state in ToTerraformValue: %s", s.state)) } @@ -131,6 +153,14 @@ func (s StringValue) Equal(other attr.Value) bool { return false } + if len(s.refinements) != len(o.refinements) { + return false + } + + if len(s.refinements) > 0 && !s.refinements.Equal(o.refinements) { + return false + } + if s.state != attr.ValueStateKnown { return true } @@ -155,7 +185,11 @@ func (s StringValue) IsUnknown() bool { // and is intended for logging and error reporting. func (s StringValue) String() string { if s.IsUnknown() { - return attr.UnknownValueString + if len(s.refinements) == 0 { + return attr.UnknownValueString + } + + return fmt.Sprintf("", s.refinements.String()) } if s.IsNull() { @@ -185,3 +219,102 @@ func (s StringValue) ValueStringPointer() *string { func (s StringValue) ToStringValue(context.Context) (StringValue, diag.Diagnostics) { return s, nil } + +// RefineAsNotNull will return a new unknown StringValue that includes a value refinement that: +// - Indicates the string value will not be null once it becomes known. +// +// If the provided StringValue is null or known, then the StringValue will be returned unchanged. +func (s StringValue) RefineAsNotNull() StringValue { + if !s.IsUnknown() { + return s + } + + newRefinements := make(refinement.Refinements, len(s.refinements)) + for i, refn := range s.refinements { + newRefinements[i] = refn + } + + newRefinements[refinement.KeyNotNull] = refinement.NewNotNull() + + newUnknownVal := NewStringUnknown() + newUnknownVal.refinements = newRefinements + + return newUnknownVal +} + +// RefineWithPrefix will return an unknown StringValue that includes a value refinement that: +// - Indicates the string value will not be null once it becomes known. +// - Indicates the string value will have the specified prefix once it becomes known. +// +// Prefixes that exceed 256 characters in length will be truncated and empty string prefixes +// will be ignored. If the provided StringValue is null or known, then the StringValue will be +// returned unchanged. +func (s StringValue) RefineWithPrefix(prefix string) StringValue { + if !s.IsUnknown() { + return s + } + + newRefinements := make(refinement.Refinements, len(s.refinements)) + for i, refn := range s.refinements { + newRefinements[i] = refn + } + + newRefinements[refinement.KeyNotNull] = refinement.NewNotNull() + + // No need to encode an empty prefix, since terraform-plugin-go will ignore it anyways. + if prefix != "" { + newRefinements[refinement.KeyStringPrefix] = refinement.NewStringPrefix(prefix) + } + + newUnknownVal := NewStringUnknown() + newUnknownVal.refinements = newRefinements + + return newUnknownVal +} + +// NotNullRefinement returns value refinement data and a boolean indicating if a NotNull refinement +// exists on the given StringValue. If a StringValue contains a NotNull refinement, this indicates +// that the string is unknown, but the eventual known value will not be null. +// +// A NotNull value refinement can be added to an unknown value via the `RefineAsNotNull` method. +func (s StringValue) NotNullRefinement() (*refinement.NotNull, bool) { + if !s.IsUnknown() { + return nil, false + } + + refn, ok := s.refinements[refinement.KeyNotNull] + if !ok { + return nil, false + } + + notNullRefn, ok := refn.(refinement.NotNull) + if !ok { + return nil, false + } + + return ¬NullRefn, true +} + +// PrefixRefinement returns value refinement data and a boolean indicating if a StringPrefix refinement +// exists on the given StringValue. If a StringValue contains a StringPrefix refinement, this indicates +// that the string is unknown, but the eventual known value will have a specified string prefix. +// The returned boolean should be checked before accessing refinement data. +// +// A StringPrefix value refinement can be added to an unknown value via the `RefineWithPrefix` method. +func (s StringValue) PrefixRefinement() (*refinement.StringPrefix, bool) { + if !s.IsUnknown() { + return nil, false + } + + refn, ok := s.refinements[refinement.KeyStringPrefix] + if !ok { + return nil, false + } + + prefixRefn, ok := refn.(refinement.StringPrefix) + if !ok { + return nil, false + } + + return &prefixRefn, true +} diff --git a/types/basetypes/string_value_test.go b/types/basetypes/string_value_test.go index 583fd2f2..9d4ad9c4 100644 --- a/types/basetypes/string_value_test.go +++ b/types/basetypes/string_value_test.go @@ -9,7 +9,9 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types/refinement" "github.com/hashicorp/terraform-plugin-go/tftypes" + tfrefinement "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" ) func TestStringValueToTerraformValue(t *testing.T) { @@ -28,6 +30,19 @@ func TestStringValueToTerraformValue(t *testing.T) { input: NewStringUnknown(), expectation: tftypes.NewValue(tftypes.String, tftypes.UnknownValue), }, + "unknown-with-notnull-refinement": { + input: NewStringUnknown().RefineAsNotNull(), + expectation: tftypes.NewValue(tftypes.String, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + }), + }, + "unknown-with-prefix-refinement": { + input: NewStringUnknown().RefineWithPrefix("hello://"), + expectation: tftypes.NewValue(tftypes.String, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + tfrefinement.KeyStringPrefix: tfrefinement.NewStringPrefix("hello://"), + }), + }, "null": { input: NewStringNull(), expectation: tftypes.NewValue(tftypes.String, nil), @@ -90,6 +105,31 @@ func TestStringValueEqual(t *testing.T) { candidate: NewStringUnknown(), expectation: true, }, + "unknown-unknown-with-notnull-refinement": { + input: NewStringUnknown(), + candidate: NewStringUnknown().RefineAsNotNull(), + expectation: false, + }, + "unknown-unknown-with-prefix-refinement": { + input: NewStringUnknown(), + candidate: NewStringUnknown().RefineWithPrefix("hello://"), + expectation: false, + }, + "unknowns-with-matching-notnull-refinements": { + input: NewStringUnknown().RefineAsNotNull(), + candidate: NewStringUnknown().RefineAsNotNull(), + expectation: true, + }, + "unknowns-with-matching-prefix-refinements": { + input: NewStringUnknown().RefineWithPrefix("hello://"), + candidate: NewStringUnknown().RefineWithPrefix("hello://"), + expectation: true, + }, + "unknowns-with-different-prefix-refinements": { + input: NewStringUnknown().RefineWithPrefix("hello://"), + candidate: NewStringUnknown().RefineWithPrefix("world://"), + expectation: false, + }, "unknown-null": { input: NewStringUnknown(), candidate: NewStringNull(), @@ -220,6 +260,14 @@ func TestStringValueString(t *testing.T) { input: NewStringUnknown(), expectation: "", }, + "unknown-with-notnull-refinement": { + input: NewStringUnknown().RefineAsNotNull(), + expectation: "", + }, + "unknown-with-prefix-refinement": { + input: NewStringUnknown().RefineWithPrefix("hello://"), + expectation: ``, + }, "null": { input: NewStringNull(), expectation: "", @@ -346,3 +394,113 @@ func TestNewStringPointerValue(t *testing.T) { }) } } + +func TestStringValue_NotNullRefinement(t *testing.T) { + t.Parallel() + + type testCase struct { + input StringValue + expectedRefnVal refinement.Refinement + expectedFound bool + } + tests := map[string]testCase{ + "known-ignored": { + input: NewStringValue("test").RefineAsNotNull(), + expectedFound: false, + }, + "null-ignored": { + input: NewStringNull().RefineAsNotNull(), + expectedFound: false, + }, + "unknown-no-refinement": { + input: NewStringUnknown(), + expectedFound: false, + }, + "unknown-with-notnull-refinement": { + input: NewStringUnknown().RefineAsNotNull(), + expectedRefnVal: refinement.NewNotNull(), + expectedFound: true, + }, + } + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, found := test.input.NotNullRefinement() + if found != test.expectedFound { + t.Fatalf("Expected refinement exists to be: %t, got: %t", test.expectedFound, found) + } + + if got == nil && test.expectedRefnVal == nil { + // Success! + return + } + + if got == nil && test.expectedRefnVal != nil { + t.Fatalf("Expected refinement data: <%+v>, got: nil", test.expectedRefnVal) + } + + if diff := cmp.Diff(*got, test.expectedRefnVal); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func TestStringValue_PrefixRefinement(t *testing.T) { + t.Parallel() + + type testCase struct { + input StringValue + expectedRefnVal refinement.Refinement + expectedFound bool + } + tests := map[string]testCase{ + "known-ignored": { + input: NewStringValue("test").RefineWithPrefix("hello://"), + expectedFound: false, + }, + "null-ignored": { + input: NewStringNull().RefineWithPrefix("hello://"), + expectedFound: false, + }, + "unknown-no-refinement": { + input: NewStringUnknown(), + expectedFound: false, + }, + "unknown-with-empty-prefix-refinement": { + input: NewStringUnknown().RefineWithPrefix(""), + expectedFound: false, + }, + "unknown-with-prefix-refinement": { + input: NewStringUnknown().RefineWithPrefix("hello://"), + expectedRefnVal: refinement.NewStringPrefix("hello://"), + expectedFound: true, + }, + } + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, found := test.input.PrefixRefinement() + if found != test.expectedFound { + t.Fatalf("Expected refinement exists to be: %t, got: %t", test.expectedFound, found) + } + + if got == nil && test.expectedRefnVal == nil { + // Success! + return + } + + if got == nil && test.expectedRefnVal != nil { + t.Fatalf("Expected refinement data: <%+v>, got: nil", test.expectedRefnVal) + } + + if diff := cmp.Diff(*got, test.expectedRefnVal); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/types/basetypes/tuple_type.go b/types/basetypes/tuple_type.go index 89718268..e6bf839e 100644 --- a/types/basetypes/tuple_type.go +++ b/types/basetypes/tuple_type.go @@ -10,6 +10,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-go/tftypes" + tfrefinement "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" ) var ( @@ -105,7 +106,29 @@ func (t TupleType) ValueFromTerraform(ctx context.Context, in tftypes.Value) (at return nil, fmt.Errorf("expected %s, got %s", t.TerraformType(ctx), in.Type()) } if !in.IsKnown() { - return NewTupleUnknown(t.ElementTypes()), nil + unknownVal := NewTupleUnknown(t.ElementTypes()) + refinements := in.Refinements() + + if len(refinements) == 0 { + return unknownVal, nil + } + + for _, refn := range refinements { + switch refnVal := refn.(type) { + case tfrefinement.Nullness: + if !refnVal.Nullness() { + unknownVal = unknownVal.RefineAsNotNull() + } else { + // This scenario shouldn't occur, as Terraform should have already collapsed an + // unknown value with a definitely null refinement into a known null value. However, + // the protocol encoding does support this refinement value, so we'll also just collapse + // it into a known null value here. + return NewTupleNull(t.ElementTypes()), nil + } + } + } + + return unknownVal, nil } if in.IsNull() { return NewTupleNull(t.ElementTypes()), nil diff --git a/types/basetypes/tuple_type_test.go b/types/basetypes/tuple_type_test.go index 65557abb..c396f80d 100644 --- a/types/basetypes/tuple_type_test.go +++ b/types/basetypes/tuple_type_test.go @@ -10,6 +10,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-go/tftypes" + tfrefinement "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" ) func TestTupleTypeEqual(t *testing.T) { @@ -334,6 +335,17 @@ func TestTupleTypeValueFromTerraform(t *testing.T) { }, tftypes.UnknownValue), expected: NewTupleUnknown([]attr.Type{StringType{}, BoolType{}, DynamicType{}}), }, + "unknown-tuple-with-notnull-refinement": { + receiver: TupleType{ + ElemTypes: []attr.Type{StringType{}, BoolType{}}, + }, + input: tftypes.NewValue(tftypes.Tuple{ + ElementTypes: []tftypes.Type{tftypes.String, tftypes.Bool}, + }, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + }), + expected: NewTupleUnknown([]attr.Type{StringType{}, BoolType{}}).RefineAsNotNull(), + }, "partially-unknown-tuple": { receiver: TupleType{ ElemTypes: []attr.Type{StringType{}, BoolType{}, DynamicType{}, DynamicType{}}, @@ -469,3 +481,27 @@ func TestTupleTypeValueFromTerraform(t *testing.T) { }) } } + +func TestTupleTypeValueFromTerraform_RefinementNullCollapse(t *testing.T) { + t.Parallel() + + receiver := TupleType{ + ElemTypes: []attr.Type{StringType{}, BoolType{}}, + } + + // This shouldn't happen, but this test ensures that if we receive this kind of refinement, that we will + // convert it to a known null value. + input := tftypes.NewValue(receiver.TerraformType(context.Background()), tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(true), + }) + expectation := NewTupleNull(receiver.ElementTypes()) + + got, err := receiver.ValueFromTerraform(context.Background(), input) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + + if !got.Equal(expectation) { + t.Errorf("Expected %+v, got %+v", expectation, got) + } +} diff --git a/types/basetypes/tuple_value.go b/types/basetypes/tuple_value.go index 5987d382..c3689616 100644 --- a/types/basetypes/tuple_value.go +++ b/types/basetypes/tuple_value.go @@ -10,10 +10,15 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types/refinement" "github.com/hashicorp/terraform-plugin-go/tftypes" + tfrefinement "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" ) -var _ attr.Value = TupleValue{} +var ( + _ attr.Value = TupleValue{} + _ attr.ValueWithNotNullRefinement = TupleValue{} +) // NewTupleNull creates a Tuple with a null value. func NewTupleNull(elementTypes []attr.Type) TupleValue { @@ -120,6 +125,10 @@ type TupleValue struct { // state represents whether the value is null, unknown, or known. The // zero-value is null. state attr.ValueState + + // refinements represents the unknown value refinement data associated with this Value. + // This field is only populated for unknown values. + refinements refinement.Refinements } // Elements returns a copy of the ordered list of known values for the Tuple. @@ -159,6 +168,14 @@ func (v TupleValue) Equal(o attr.Value) bool { return false } + if len(v.refinements) != len(other.refinements) { + return false + } + + if len(v.refinements) > 0 && !v.refinements.Equal(other.refinements) { + return false + } + if v.state != attr.ValueStateKnown { return true } @@ -192,7 +209,11 @@ func (v TupleValue) IsUnknown() bool { // compatibility guarantees, and is intended for logging and error reporting. func (v TupleValue) String() string { if v.IsUnknown() { - return attr.UnknownValueString + if len(v.refinements) == 0 { + return attr.UnknownValueString + } + + return fmt.Sprintf("", v.refinements.String()) } if v.IsNull() { @@ -247,8 +268,66 @@ func (v TupleValue) ToTerraformValue(ctx context.Context) (tftypes.Value, error) case attr.ValueStateNull: return tftypes.NewValue(tupleType, nil), nil case attr.ValueStateUnknown: - return tftypes.NewValue(tupleType, tftypes.UnknownValue), nil + if len(v.refinements) == 0 { + return tftypes.NewValue(tupleType, tftypes.UnknownValue), nil + } + + unknownValRefinements := make(tfrefinement.Refinements, 0) + for _, refn := range v.refinements { + switch refn.(type) { + case refinement.NotNull: + unknownValRefinements[tfrefinement.KeyNullness] = tfrefinement.NewNullness(false) + } + } + unknownVal := tftypes.NewValue(tupleType, tftypes.UnknownValue) + + return unknownVal.Refine(unknownValRefinements), nil default: panic(fmt.Sprintf("unhandled Tuple state in ToTerraformValue: %s", v.state)) } } + +// RefineAsNotNull will return a new unknown TupleValue that includes a value refinement that: +// - Indicates the tuple value will not be null once it becomes known. +// +// If the provided TupleValue is null or known, then the TupleValue will be returned unchanged. +func (v TupleValue) RefineAsNotNull() TupleValue { + if !v.IsUnknown() { + return v + } + + newRefinements := make(refinement.Refinements, len(v.refinements)) + for i, refn := range v.refinements { + newRefinements[i] = refn + } + + newRefinements[refinement.KeyNotNull] = refinement.NewNotNull() + + newUnknownVal := NewTupleUnknown(v.ElementTypes(context.Background())) + newUnknownVal.refinements = newRefinements + + return newUnknownVal +} + +// NotNullRefinement returns value refinement data and a boolean indicating if a NotNull refinement +// exists on the given TupleValue. If a TupleValue contains a NotNull refinement, this indicates +// that the tuple is unknown, but the eventual known value will not be null. +// +// A NotNull value refinement can be added to an unknown value via the `RefineAsNotNull` method. +func (v TupleValue) NotNullRefinement() (*refinement.NotNull, bool) { + if !v.IsUnknown() { + return nil, false + } + + refn, ok := v.refinements[refinement.KeyNotNull] + if !ok { + return nil, false + } + + notNullRefn, ok := refn.(refinement.NotNull) + if !ok { + return nil, false + } + + return ¬NullRefn, true +} diff --git a/types/basetypes/tuple_value_test.go b/types/basetypes/tuple_value_test.go index 34512bab..ca0d6430 100644 --- a/types/basetypes/tuple_value_test.go +++ b/types/basetypes/tuple_value_test.go @@ -10,7 +10,9 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types/refinement" "github.com/hashicorp/terraform-plugin-go/tftypes" + tfrefinement "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" ) func TestNewTupleValue(t *testing.T) { @@ -396,6 +398,16 @@ func TestTupleValueEqual(t *testing.T) { input: nil, expected: false, }, + "unknown-unknown-with-notnull-refinement": { + receiver: NewTupleUnknown([]attr.Type{StringType{}, Int64Type{}}), + input: NewTupleUnknown([]attr.Type{StringType{}, Int64Type{}}).RefineAsNotNull(), + expected: false, + }, + "unknowns-with-matching-notnull-refinements": { + receiver: NewTupleUnknown([]attr.Type{StringType{}, Int64Type{}}).RefineAsNotNull(), + input: NewTupleUnknown([]attr.Type{StringType{}, Int64Type{}}).RefineAsNotNull(), + expected: true, + }, } for name, test := range tests { name, test := name, test @@ -545,6 +557,10 @@ func TestTupleValueString(t *testing.T) { input: NewTupleUnknown([]attr.Type{StringType{}, BoolType{}}), expectation: "", }, + "unknown-with-notnull-refinement": { + input: NewTupleUnknown([]attr.Type{StringType{}, BoolType{}}).RefineAsNotNull(), + expectation: "", + }, "null": { input: NewTupleNull([]attr.Type{StringType{}, BoolType{}}), expectation: "", @@ -711,6 +727,12 @@ func TestTupleValueToTerraformValue(t *testing.T) { input: NewTupleUnknown([]attr.Type{StringType{}, BoolType{}, DynamicType{}}), expectation: tftypes.NewValue(tftypes.Tuple{ElementTypes: []tftypes.Type{tftypes.String, tftypes.Bool, tftypes.DynamicPseudoType}}, tftypes.UnknownValue), }, + "unknown-with-notnull-refinement": { + input: NewTupleUnknown([]attr.Type{StringType{}, BoolType{}}).RefineAsNotNull(), + expectation: tftypes.NewValue(tftypes.Tuple{ElementTypes: []tftypes.Type{tftypes.String, tftypes.Bool}}, tftypes.UnknownValue).Refine(tfrefinement.Refinements{ + tfrefinement.KeyNullness: tfrefinement.NewNullness(false), + }), + }, "null": { input: NewTupleNull([]attr.Type{StringType{}, BoolType{}, DynamicType{}}), expectation: tftypes.NewValue(tftypes.Tuple{ElementTypes: []tftypes.Type{tftypes.String, tftypes.Bool, tftypes.DynamicPseudoType}}, nil), @@ -746,3 +768,61 @@ func TestTupleValueToTerraformValue(t *testing.T) { }) } } + +func TestTupleValue_NotNullRefinement(t *testing.T) { + t.Parallel() + + type testCase struct { + input TupleValue + expectedRefnVal refinement.Refinement + expectedFound bool + } + tests := map[string]testCase{ + "known-ignored": { + input: NewTupleValueMust( + []attr.Type{StringType{}, BoolType{}}, + []attr.Value{ + NewStringNull(), + NewBoolValue(true), + }).RefineAsNotNull(), + expectedFound: false, + }, + "null-ignored": { + input: NewTupleNull([]attr.Type{StringType{}, BoolType{}}).RefineAsNotNull(), + expectedFound: false, + }, + "unknown-no-refinement": { + input: NewTupleUnknown([]attr.Type{StringType{}, BoolType{}}), + expectedFound: false, + }, + "unknown-with-notnull-refinement": { + input: NewTupleUnknown([]attr.Type{StringType{}, BoolType{}}).RefineAsNotNull(), + expectedRefnVal: refinement.NewNotNull(), + expectedFound: true, + }, + } + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, found := test.input.NotNullRefinement() + if found != test.expectedFound { + t.Fatalf("Expected refinement exists to be: %t, got: %t", test.expectedFound, found) + } + + if got == nil && test.expectedRefnVal == nil { + // Success! + return + } + + if got == nil && test.expectedRefnVal != nil { + t.Fatalf("Expected refinement data: <%+v>, got: nil", test.expectedRefnVal) + } + + if diff := cmp.Diff(*got, test.expectedRefnVal); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} diff --git a/types/refinement/collection_length_lower_bound.go b/types/refinement/collection_length_lower_bound.go new file mode 100644 index 00000000..748b6503 --- /dev/null +++ b/types/refinement/collection_length_lower_bound.go @@ -0,0 +1,40 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package refinement + +import "fmt" + +// CollectionLengthLowerBound represents an unknown value refinement which indicates the length of the final collection value will be +// at least the specified int64 value. This refinement can only be applied to types.List, types.Map, and types.Set. +type CollectionLengthLowerBound struct { + value int64 +} + +func (n CollectionLengthLowerBound) Equal(other Refinement) bool { + otherVal, ok := other.(CollectionLengthLowerBound) + if !ok { + return false + } + + return n.LowerBound() == otherVal.LowerBound() +} + +func (n CollectionLengthLowerBound) String() string { + return fmt.Sprintf("length lower bound = %d", n.LowerBound()) +} + +// LowerBound returns the int64 value that the final value's collection length will be at least. +func (n CollectionLengthLowerBound) LowerBound() int64 { + return n.value +} + +func (n CollectionLengthLowerBound) unimplementable() {} + +// NewCollectionLengthLowerBound returns the CollectionLengthLowerBound unknown value refinement which indicates the length of the final +// collection value will be at least the specified int64 value. This refinement can only be applied to types.List, types.Map, and types.Set. +func NewCollectionLengthLowerBound(value int64) Refinement { + return CollectionLengthLowerBound{ + value: value, + } +} diff --git a/types/refinement/collection_length_upper_bound.go b/types/refinement/collection_length_upper_bound.go new file mode 100644 index 00000000..4a3cf3e5 --- /dev/null +++ b/types/refinement/collection_length_upper_bound.go @@ -0,0 +1,40 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package refinement + +import "fmt" + +// CollectionLengthUpperBound represents an unknown value refinement which indicates the length of the final collection value will be +// at most the specified int64 value. This refinement can only be applied to types.List, types.Map, and types.Set. +type CollectionLengthUpperBound struct { + value int64 +} + +func (n CollectionLengthUpperBound) Equal(other Refinement) bool { + otherVal, ok := other.(CollectionLengthUpperBound) + if !ok { + return false + } + + return n.UpperBound() == otherVal.UpperBound() +} + +func (n CollectionLengthUpperBound) String() string { + return fmt.Sprintf("length upper bound = %d", n.UpperBound()) +} + +// UpperBound returns the int64 value that the final value's collection length will be at most. +func (n CollectionLengthUpperBound) UpperBound() int64 { + return n.value +} + +func (n CollectionLengthUpperBound) unimplementable() {} + +// NewCollectionLengthUpperBound returns the CollectionLengthUpperBound unknown value refinement which indicates the length of the final +// collection value will be at most the specified int64 value. This refinement can only be applied to types.List, types.Map, and types.Set. +func NewCollectionLengthUpperBound(value int64) Refinement { + return CollectionLengthUpperBound{ + value: value, + } +} diff --git a/types/refinement/doc.go b/types/refinement/doc.go new file mode 100644 index 00000000..84ffb016 --- /dev/null +++ b/types/refinement/doc.go @@ -0,0 +1,11 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +// The refinement package contains the interfaces and structs that represent unknown value refinement data. Refinements contain +// additional constraints about unknown values and what their eventual known values can be. In certain scenarios, Terraform can +// use these constraints to produce known results from unknown values. (like evaluating a count expression comparing an unknown +// value to "null") +// +// Unknown value refinements can be added to an `attr.Value` via the specific type implementations in the `basetypes` package. +// Set refinement data with the `Refine*` methods and retrieve refinement data with the `*Refinement` methods. +package refinement diff --git a/types/refinement/float32_lower_bound.go b/types/refinement/float32_lower_bound.go new file mode 100644 index 00000000..0a1d2cc9 --- /dev/null +++ b/types/refinement/float32_lower_bound.go @@ -0,0 +1,53 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package refinement + +import "fmt" + +// Float32LowerBound represents an unknown value refinement that indicates the final value will not be less than the specified +// float32 value, as well as whether that bound is inclusive or exclusive. This refinement can only be applied to types.Float32. +type Float32LowerBound struct { + inclusive bool + value float32 +} + +func (i Float32LowerBound) Equal(other Refinement) bool { + otherVal, ok := other.(Float32LowerBound) + if !ok { + return false + } + + return i.IsInclusive() == otherVal.IsInclusive() && i.LowerBound() == otherVal.LowerBound() +} + +func (i Float32LowerBound) String() string { + rangeDescription := "inclusive" + if !i.IsInclusive() { + rangeDescription = "exclusive" + } + + return fmt.Sprintf("lower bound = %f (%s)", i.LowerBound(), rangeDescription) +} + +// IsInclusive returns whether the bound returned by the `LowerBound` method is inclusive or exclusive. +func (i Float32LowerBound) IsInclusive() bool { + return i.inclusive +} + +// LowerBound returns the float32 value that the final value will not be less than. The `IsInclusive` method must also be used during +// comparison to determine whether the bound is inclusive or exclusive. +func (i Float32LowerBound) LowerBound() float32 { + return i.value +} + +func (i Float32LowerBound) unimplementable() {} + +// NewFloat32LowerBound returns the Float32LowerBound unknown value refinement that indicates the final value will not be less than the specified +// float32 value, as well as whether that bound is inclusive or exclusive. This refinement can only be applied to types.Float32. +func NewFloat32LowerBound(value float32, inclusive bool) Refinement { + return Float32LowerBound{ + value: value, + inclusive: inclusive, + } +} diff --git a/types/refinement/float32_upper_bound.go b/types/refinement/float32_upper_bound.go new file mode 100644 index 00000000..95779f76 --- /dev/null +++ b/types/refinement/float32_upper_bound.go @@ -0,0 +1,53 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package refinement + +import "fmt" + +// Float32UpperBound represents an unknown value refinement that indicates the final value will not be greater than the specified +// float32 value, as well as whether that bound is inclusive or exclusive. This refinement can only be applied to types.Float32. +type Float32UpperBound struct { + inclusive bool + value float32 +} + +func (i Float32UpperBound) Equal(other Refinement) bool { + otherVal, ok := other.(Float32UpperBound) + if !ok { + return false + } + + return i.IsInclusive() == otherVal.IsInclusive() && i.UpperBound() == otherVal.UpperBound() +} + +func (i Float32UpperBound) String() string { + rangeDescription := "inclusive" + if !i.IsInclusive() { + rangeDescription = "exclusive" + } + + return fmt.Sprintf("upper bound = %f (%s)", i.UpperBound(), rangeDescription) +} + +// IsInclusive returns whether the bound returned by the `UpperBound` method is inclusive or exclusive. +func (i Float32UpperBound) IsInclusive() bool { + return i.inclusive +} + +// UpperBound returns the float32 value that the final value will not be greater than. The `IsInclusive` method must also be used during +// comparison to determine whether the bound is inclusive or exclusive. +func (i Float32UpperBound) UpperBound() float32 { + return i.value +} + +func (i Float32UpperBound) unimplementable() {} + +// NewFloat32UpperBound returns the Float32UpperBound unknown value refinement that indicates the final value will not be greater than the specified +// float32 value, as well as whether that bound is inclusive or exclusive. This refinement can only be applied to types.Float32. +func NewFloat32UpperBound(value float32, inclusive bool) Refinement { + return Float32UpperBound{ + value: value, + inclusive: inclusive, + } +} diff --git a/types/refinement/float64_lower_bound.go b/types/refinement/float64_lower_bound.go new file mode 100644 index 00000000..5b89f776 --- /dev/null +++ b/types/refinement/float64_lower_bound.go @@ -0,0 +1,53 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package refinement + +import "fmt" + +// Float64LowerBound represents an unknown value refinement that indicates the final value will not be less than the specified +// float64 value, as well as whether that bound is inclusive or exclusive. This refinement can only be applied to types.Float64. +type Float64LowerBound struct { + inclusive bool + value float64 +} + +func (i Float64LowerBound) Equal(other Refinement) bool { + otherVal, ok := other.(Float64LowerBound) + if !ok { + return false + } + + return i.IsInclusive() == otherVal.IsInclusive() && i.LowerBound() == otherVal.LowerBound() +} + +func (i Float64LowerBound) String() string { + rangeDescription := "inclusive" + if !i.IsInclusive() { + rangeDescription = "exclusive" + } + + return fmt.Sprintf("lower bound = %f (%s)", i.LowerBound(), rangeDescription) +} + +// IsInclusive returns whether the bound returned by the `LowerBound` method is inclusive or exclusive. +func (i Float64LowerBound) IsInclusive() bool { + return i.inclusive +} + +// LowerBound returns the float64 value that the final value will not be less than. The `IsInclusive` method must also be used during +// comparison to determine whether the bound is inclusive or exclusive. +func (i Float64LowerBound) LowerBound() float64 { + return i.value +} + +func (i Float64LowerBound) unimplementable() {} + +// NewFloat64LowerBound returns the Float64LowerBound unknown value refinement that indicates the final value will not be less than the specified +// float64 value, as well as whether that bound is inclusive or exclusive. This refinement can only be applied to types.Float64. +func NewFloat64LowerBound(value float64, inclusive bool) Refinement { + return Float64LowerBound{ + value: value, + inclusive: inclusive, + } +} diff --git a/types/refinement/float64_upper_bound.go b/types/refinement/float64_upper_bound.go new file mode 100644 index 00000000..fe5aacb7 --- /dev/null +++ b/types/refinement/float64_upper_bound.go @@ -0,0 +1,53 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package refinement + +import "fmt" + +// Float64UpperBound represents an unknown value refinement that indicates the final value will not be greater than the specified +// float64 value, as well as whether that bound is inclusive or exclusive. This refinement can only be applied to types.Float64. +type Float64UpperBound struct { + inclusive bool + value float64 +} + +func (i Float64UpperBound) Equal(other Refinement) bool { + otherVal, ok := other.(Float64UpperBound) + if !ok { + return false + } + + return i.IsInclusive() == otherVal.IsInclusive() && i.UpperBound() == otherVal.UpperBound() +} + +func (i Float64UpperBound) String() string { + rangeDescription := "inclusive" + if !i.IsInclusive() { + rangeDescription = "exclusive" + } + + return fmt.Sprintf("upper bound = %f (%s)", i.UpperBound(), rangeDescription) +} + +// IsInclusive returns whether the bound returned by the `UpperBound` method is inclusive or exclusive. +func (i Float64UpperBound) IsInclusive() bool { + return i.inclusive +} + +// UpperBound returns the float64 value that the final value will not be greater than. The `IsInclusive` method must also be used during +// comparison to determine whether the bound is inclusive or exclusive. +func (i Float64UpperBound) UpperBound() float64 { + return i.value +} + +func (i Float64UpperBound) unimplementable() {} + +// NewFloat64UpperBound returns the Float64UpperBound unknown value refinement that indicates the final value will not be greater than the specified +// float64 value, as well as whether that bound is inclusive or exclusive. This refinement can only be applied to types.Float64. +func NewFloat64UpperBound(value float64, inclusive bool) Refinement { + return Float64UpperBound{ + value: value, + inclusive: inclusive, + } +} diff --git a/types/refinement/int32_lower_bound.go b/types/refinement/int32_lower_bound.go new file mode 100644 index 00000000..3e0c533e --- /dev/null +++ b/types/refinement/int32_lower_bound.go @@ -0,0 +1,53 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package refinement + +import "fmt" + +// Int32LowerBound represents an unknown value refinement that indicates the final value will not be less than the specified +// int32 value, as well as whether that bound is inclusive or exclusive. This refinement can only be applied to types.Int32. +type Int32LowerBound struct { + inclusive bool + value int32 +} + +func (i Int32LowerBound) Equal(other Refinement) bool { + otherVal, ok := other.(Int32LowerBound) + if !ok { + return false + } + + return i.IsInclusive() == otherVal.IsInclusive() && i.LowerBound() == otherVal.LowerBound() +} + +func (i Int32LowerBound) String() string { + rangeDescription := "inclusive" + if !i.IsInclusive() { + rangeDescription = "exclusive" + } + + return fmt.Sprintf("lower bound = %d (%s)", i.LowerBound(), rangeDescription) +} + +// IsInclusive returns whether the bound returned by the `LowerBound` method is inclusive or exclusive. +func (i Int32LowerBound) IsInclusive() bool { + return i.inclusive +} + +// LowerBound returns the int32 value that the final value will not be less than. The `IsInclusive` method must also be used during +// comparison to determine whether the bound is inclusive or exclusive. +func (i Int32LowerBound) LowerBound() int32 { + return i.value +} + +func (i Int32LowerBound) unimplementable() {} + +// NewInt32LowerBound returns the Int32LowerBound unknown value refinement that indicates the final value will not be less than the specified +// int32 value, as well as whether that bound is inclusive or exclusive. This refinement can only be applied to types.Int32. +func NewInt32LowerBound(value int32, inclusive bool) Refinement { + return Int32LowerBound{ + value: value, + inclusive: inclusive, + } +} diff --git a/types/refinement/int32_upper_bound.go b/types/refinement/int32_upper_bound.go new file mode 100644 index 00000000..35887fc7 --- /dev/null +++ b/types/refinement/int32_upper_bound.go @@ -0,0 +1,53 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package refinement + +import "fmt" + +// Int32UpperBound represents an unknown value refinement that indicates the final value will not be greater than the specified +// int32 value, as well as whether that bound is inclusive or exclusive. This refinement can only be applied to types.Int32. +type Int32UpperBound struct { + inclusive bool + value int32 +} + +func (i Int32UpperBound) Equal(other Refinement) bool { + otherVal, ok := other.(Int32UpperBound) + if !ok { + return false + } + + return i.IsInclusive() == otherVal.IsInclusive() && i.UpperBound() == otherVal.UpperBound() +} + +func (i Int32UpperBound) String() string { + rangeDescription := "inclusive" + if !i.IsInclusive() { + rangeDescription = "exclusive" + } + + return fmt.Sprintf("upper bound = %d (%s)", i.UpperBound(), rangeDescription) +} + +// IsInclusive returns whether the bound returned by the `UpperBound` method is inclusive or exclusive. +func (i Int32UpperBound) IsInclusive() bool { + return i.inclusive +} + +// UpperBound returns the int32 value that the final value will not be greater than. The `IsInclusive` method must also be used during +// comparison to determine whether the bound is inclusive or exclusive. +func (i Int32UpperBound) UpperBound() int32 { + return i.value +} + +func (i Int32UpperBound) unimplementable() {} + +// NewInt32UpperBound returns the Int32UpperBound unknown value refinement that indicates the final value will not be greater than the specified +// int32 value, as well as whether that bound is inclusive or exclusive. This refinement can only be applied to types.Int32. +func NewInt32UpperBound(value int32, inclusive bool) Refinement { + return Int32UpperBound{ + value: value, + inclusive: inclusive, + } +} diff --git a/types/refinement/int64_lower_bound.go b/types/refinement/int64_lower_bound.go new file mode 100644 index 00000000..dca9efca --- /dev/null +++ b/types/refinement/int64_lower_bound.go @@ -0,0 +1,53 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package refinement + +import "fmt" + +// Int64LowerBound represents an unknown value refinement that indicates the final value will not be less than the specified +// int64 value, as well as whether that bound is inclusive or exclusive. This refinement can only be applied to types.Int64. +type Int64LowerBound struct { + inclusive bool + value int64 +} + +func (i Int64LowerBound) Equal(other Refinement) bool { + otherVal, ok := other.(Int64LowerBound) + if !ok { + return false + } + + return i.IsInclusive() == otherVal.IsInclusive() && i.LowerBound() == otherVal.LowerBound() +} + +func (i Int64LowerBound) String() string { + rangeDescription := "inclusive" + if !i.IsInclusive() { + rangeDescription = "exclusive" + } + + return fmt.Sprintf("lower bound = %d (%s)", i.LowerBound(), rangeDescription) +} + +// IsInclusive returns whether the bound returned by the `LowerBound` method is inclusive or exclusive. +func (i Int64LowerBound) IsInclusive() bool { + return i.inclusive +} + +// LowerBound returns the int64 value that the final value will not be less than. The `IsInclusive` method must also be used during +// comparison to determine whether the bound is inclusive or exclusive. +func (i Int64LowerBound) LowerBound() int64 { + return i.value +} + +func (i Int64LowerBound) unimplementable() {} + +// NewInt64LowerBound returns the Int64LowerBound unknown value refinement that indicates the final value will not be less than the specified +// int64 value, as well as whether that bound is inclusive or exclusive. This refinement can only be applied to types.Int64. +func NewInt64LowerBound(value int64, inclusive bool) Refinement { + return Int64LowerBound{ + value: value, + inclusive: inclusive, + } +} diff --git a/types/refinement/int64_upper_bound.go b/types/refinement/int64_upper_bound.go new file mode 100644 index 00000000..b243ce6f --- /dev/null +++ b/types/refinement/int64_upper_bound.go @@ -0,0 +1,53 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package refinement + +import "fmt" + +// Int64UpperBound represents an unknown value refinement that indicates the final value will not be greater than the specified +// int64 value, as well as whether that bound is inclusive or exclusive. This refinement can only be applied to types.Int64. +type Int64UpperBound struct { + inclusive bool + value int64 +} + +func (i Int64UpperBound) Equal(other Refinement) bool { + otherVal, ok := other.(Int64UpperBound) + if !ok { + return false + } + + return i.IsInclusive() == otherVal.IsInclusive() && i.UpperBound() == otherVal.UpperBound() +} + +func (i Int64UpperBound) String() string { + rangeDescription := "inclusive" + if !i.IsInclusive() { + rangeDescription = "exclusive" + } + + return fmt.Sprintf("upper bound = %d (%s)", i.UpperBound(), rangeDescription) +} + +// IsInclusive returns whether the bound returned by the `UpperBound` method is inclusive or exclusive. +func (i Int64UpperBound) IsInclusive() bool { + return i.inclusive +} + +// UpperBound returns the int64 value that the final value will not be greater than. The `IsInclusive` method must also be used during +// comparison to determine whether the bound is inclusive or exclusive. +func (i Int64UpperBound) UpperBound() int64 { + return i.value +} + +func (i Int64UpperBound) unimplementable() {} + +// NewInt64UpperBound returns the Int64UpperBound unknown value refinement that indicates the final value will not be greater than the specified +// int64 value, as well as whether that bound is inclusive or exclusive. This refinement can only be applied to types.Int64. +func NewInt64UpperBound(value int64, inclusive bool) Refinement { + return Int64UpperBound{ + value: value, + inclusive: inclusive, + } +} diff --git a/types/refinement/not_null.go b/types/refinement/not_null.go new file mode 100644 index 00000000..0122f43e --- /dev/null +++ b/types/refinement/not_null.go @@ -0,0 +1,25 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package refinement + +// NotNull represents an unknown value refinement that indicates the final value will not be null. This refinement +// can be applied to a value of any type (excluding types.Dynamic). +type NotNull struct{} + +func (n NotNull) Equal(other Refinement) bool { + _, ok := other.(NotNull) + return ok +} + +func (n NotNull) String() string { + return "not null" +} + +func (n NotNull) unimplementable() {} + +// NewNotNull returns the NotNull unknown value refinement that indicates the final value will not be null. This refinement +// can be applied to a value of any type (excluding types.Dynamic). +func NewNotNull() Refinement { + return NotNull{} +} diff --git a/types/refinement/number_lower_bound.go b/types/refinement/number_lower_bound.go new file mode 100644 index 00000000..8a143fc7 --- /dev/null +++ b/types/refinement/number_lower_bound.go @@ -0,0 +1,56 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package refinement + +import ( + "fmt" + "math/big" +) + +// NumberLowerBound represents an unknown value refinement that indicates the final value will not be less than the specified +// *big.Float value, as well as whether that bound is inclusive or exclusive. This refinement can only be applied to types.Number. +type NumberLowerBound struct { + inclusive bool + value *big.Float +} + +func (n NumberLowerBound) Equal(other Refinement) bool { + otherVal, ok := other.(NumberLowerBound) + if !ok { + return false + } + + return n.IsInclusive() == otherVal.IsInclusive() && n.LowerBound().Cmp(otherVal.LowerBound()) == 0 +} + +func (n NumberLowerBound) String() string { + rangeDescription := "inclusive" + if !n.IsInclusive() { + rangeDescription = "exclusive" + } + + return fmt.Sprintf("lower bound = %s (%s)", n.LowerBound().String(), rangeDescription) +} + +// IsInclusive returns whether the bound returned by the `LowerBound` method is inclusive or exclusive. +func (n NumberLowerBound) IsInclusive() bool { + return n.inclusive +} + +// LowerBound returns the *big.Float value that the final value will not be less than. The `IsInclusive` method must also be used during +// comparison to determine whether the bound is inclusive or exclusive. +func (n NumberLowerBound) LowerBound() *big.Float { + return n.value +} + +func (n NumberLowerBound) unimplementable() {} + +// NewNumberLowerBound returns the NumberLowerBound unknown value refinement that indicates the final value will not be less than the specified +// *big.Float value, as well as whether that bound is inclusive or exclusive. This refinement can only be applied to types.Number. +func NewNumberLowerBound(value *big.Float, inclusive bool) Refinement { + return NumberLowerBound{ + value: value, + inclusive: inclusive, + } +} diff --git a/types/refinement/number_upper_bound.go b/types/refinement/number_upper_bound.go new file mode 100644 index 00000000..fdadbd29 --- /dev/null +++ b/types/refinement/number_upper_bound.go @@ -0,0 +1,56 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package refinement + +import ( + "fmt" + "math/big" +) + +// NumberUpperBound represents an unknown value refinement that indicates the final value will not be greater than the specified +// *big.Float value, as well as whether that bound is inclusive or exclusive. This refinement can only be applied to types.Number. +type NumberUpperBound struct { + inclusive bool + value *big.Float +} + +func (n NumberUpperBound) Equal(other Refinement) bool { + otherVal, ok := other.(NumberUpperBound) + if !ok { + return false + } + + return n.IsInclusive() == otherVal.IsInclusive() && n.UpperBound().Cmp(otherVal.UpperBound()) == 0 +} + +func (n NumberUpperBound) String() string { + rangeDescription := "inclusive" + if !n.IsInclusive() { + rangeDescription = "exclusive" + } + + return fmt.Sprintf("upper bound = %s (%s)", n.UpperBound().String(), rangeDescription) +} + +// IsInclusive returns whether the bound returned by the `UpperBound` method is inclusive or exclusive. +func (n NumberUpperBound) IsInclusive() bool { + return n.inclusive +} + +// UpperBound returns the *big.Float value that the final value will not be greater than. The `IsInclusive` method must also be used during +// comparison to determine whether the bound is inclusive or exclusive. +func (n NumberUpperBound) UpperBound() *big.Float { + return n.value +} + +func (n NumberUpperBound) unimplementable() {} + +// NewNumberUpperBound returns the NumberUpperBound unknown value refinement that indicates the final value will not be greater than the specified +// *big.Float value, as well as whether that bound is inclusive or exclusive. This refinement can only be applied to types.Number. +func NewNumberUpperBound(value *big.Float, inclusive bool) Refinement { + return NumberUpperBound{ + value: value, + inclusive: inclusive, + } +} diff --git a/types/refinement/refinement.go b/types/refinement/refinement.go new file mode 100644 index 00000000..ff7067e9 --- /dev/null +++ b/types/refinement/refinement.go @@ -0,0 +1,138 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package refinement + +import ( + "fmt" + "sort" + "strings" +) + +type Key int64 + +func (k Key) String() string { + switch k { + case KeyNotNull: + return "not_null" + case KeyStringPrefix: + return "string_prefix" + case KeyNumberLowerBound: + return "number_lower_bound" + case KeyNumberUpperBound: + return "number_upper_bound" + case KeyCollectionLengthLowerBound: + return "collection_length_lower_bound" + case KeyCollectionLengthUpperBound: + return "collection_length_upper_bound" + default: + return fmt.Sprintf("unsupported refinement: %d", k) + } +} + +const ( + // KeyNotNull represents a refinement that specifies that the final value will not be null. + // + // This refinement is relevant for all types except types.Dynamic. + // + // MAINTAINER NOTE: This is named slightly different from the terraform-plugin-go `Nullness` refinement it maps to. + // This is done because framework only support nullness refinements that indicate an unknown value is definitely not null. + // Values that are definitely null should be represented as a known null value instead. + KeyNotNull = Key(1) + + // KeyStringPrefix represents a refinement that specifies a known prefix of a final string value. + // + // This refinement is only relevant for types.String. + KeyStringPrefix = Key(2) + + // KeyNumberLowerBound represents a refinement that specifies the lower bound of possible values for a final number value. + // The refinement data contains a boolean which indicates whether the bound is inclusive. + // + // This refinement is relevant for types.Int32, types.Int64, types.Float32, types.Float64, and types.Number. + // + // This Key is abstracted by the following refinements: + // - Int32LowerBound + // - Int64LowerBound + // - Float32LowerBound + // - Float64LowerBound + // - NumberLowerBound + KeyNumberLowerBound = Key(3) + + // KeyNumberUpperBound represents a refinement that specifies the upper bound of possible values for a final number value. + // The refinement data contains a boolean which indicates whether the bound is inclusive. + // + // This refinement is relevant for types.Int32, types.Int64, types.Float32, types.Float64, and types.Number. + // + // This Key is abstracted by the following refinements: + // - Int32UpperBound + // - Int64UpperBound + // - Float32UpperBound + // - Float64UpperBound + // - NumberUpperBound + KeyNumberUpperBound = Key(4) + + // KeyCollectionLengthLowerBound represents a refinement that specifies the lower bound of possible length for a final collection value. + // + // This refinement is only relevant for types.List, types.Set, and types.Map. + KeyCollectionLengthLowerBound = Key(5) + + // KeyCollectionLengthUpperBound represents a refinement that specifies the upper bound of possible length for a final collection value. + // + // This refinement is only relevant for types.List, types.Set, and types.Map. + KeyCollectionLengthUpperBound = Key(6) +) + +// Refinement represents an unknown value refinement with data constraints relevant to the final value. This interface can be asserted further +// with the associated structs in the `refinement` package to extract underlying refinement data. +type Refinement interface { + // Equal should return true if the Refinement is considered equivalent to the + // Refinement passed as an argument. + Equal(Refinement) bool + + // String should return a human-friendly version of the Refinement. + String() string + + unimplementable() // prevents external implementations, all refinements are defined in the Terraform/HCL type system go-cty. +} + +// Refinements represents a map of unknown value refinement data. +type Refinements map[Key]Refinement + +func (r Refinements) Equal(other Refinements) bool { + if len(r) != len(other) { + return false + } + + for key, refnVal := range r { + otherRefnVal, ok := other[key] + if !ok { + // Didn't find a refinement at the same key + return false + } + + if !refnVal.Equal(otherRefnVal) { + // Refinement data is not equal + return false + } + } + + return true +} +func (r Refinements) String() string { + var res strings.Builder + + keys := make([]Key, 0, len(r)) + for k := range r { + keys = append(keys, k) + } + + sort.Slice(keys, func(a, b int) bool { return keys[a] < keys[b] }) + for pos, key := range keys { + if pos != 0 { + res.WriteString(", ") + } + res.WriteString(r[key].String()) + } + + return res.String() +} diff --git a/types/refinement/string_prefix.go b/types/refinement/string_prefix.go new file mode 100644 index 00000000..5852262d --- /dev/null +++ b/types/refinement/string_prefix.go @@ -0,0 +1,42 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package refinement + +import "fmt" + +// StringPrefix represents an unknown value refinement that indicates the final value will be prefixed with the specified string value. +// String prefixes that exceed 256 characters in length will be truncated and empty string prefixes will not be encoded. This refinement can +// only be applied to the String type. +type StringPrefix struct { + value string +} + +func (s StringPrefix) Equal(other Refinement) bool { + otherVal, ok := other.(StringPrefix) + if !ok { + return false + } + + return s.PrefixValue() == otherVal.PrefixValue() +} + +func (s StringPrefix) String() string { + return fmt.Sprintf("prefix = %q", s.PrefixValue()) +} + +// PrefixValue returns the string value that the final value will be prefixed with. +func (s StringPrefix) PrefixValue() string { + return s.value +} + +func (s StringPrefix) unimplementable() {} + +// NewStringPrefix returns the StringPrefix unknown value refinement that indicates the final value will be prefixed with the specified +// string value. String prefixes that exceed 256 characters in length will be truncated and empty string prefixes will not be encoded. This +// refinement can only be applied to the String type. +func NewStringPrefix(value string) Refinement { + return StringPrefix{ + value: value, + } +}