From 64d9274662a50218060b64a4496f8e93d24745b4 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Mon, 21 Oct 2024 17:04:25 -0400 Subject: [PATCH 01/39] quick fix on equals for later --- internal/testing/testtypes/stringwithvalidateattribute.go | 4 ++-- internal/testing/testtypes/stringwithvalidateparameter.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/testing/testtypes/stringwithvalidateattribute.go b/internal/testing/testtypes/stringwithvalidateattribute.go index cb6440db..872b0fe5 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.Equal(other) } func (v StringValueWithValidateAttributeError) IsNull() bool { @@ -134,7 +134,7 @@ func (v StringValueWithValidateAttributeWarning) Equal(value attr.Value) bool { return false } - return v == other + return v.Equal(other) } func (v StringValueWithValidateAttributeWarning) IsNull() bool { diff --git a/internal/testing/testtypes/stringwithvalidateparameter.go b/internal/testing/testtypes/stringwithvalidateparameter.go index 751bbcd4..380ba516 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.Equal(other) } func (v StringValueWithValidateParameterError) IsNull() bool { From 8a8f3fcbba6412c6e699a4873ba330f43d328eeb Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Thu, 24 Oct 2024 15:56:27 -0400 Subject: [PATCH 02/39] test fixes --- types/basetypes/bool_type_test.go | 2 +- types/basetypes/float32_type_test.go | 2 +- types/basetypes/float64_type_test.go | 2 +- types/basetypes/int32_type_test.go | 2 +- types/basetypes/int64_type_test.go | 2 +- types/basetypes/number_type_test.go | 2 +- types/basetypes/string_type_test.go | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/types/basetypes/bool_type_test.go b/types/basetypes/bool_type_test.go index 8d55d830..535be353 100644 --- a/types/basetypes/bool_type_test.go +++ b/types/basetypes/bool_type_test.go @@ -61,7 +61,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 } diff --git a/types/basetypes/float32_type_test.go b/types/basetypes/float32_type_test.go index a409b35c..54b6061d 100644 --- a/types/basetypes/float32_type_test.go +++ b/types/basetypes/float32_type_test.go @@ -120,7 +120,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 } diff --git a/types/basetypes/float64_type_test.go b/types/basetypes/float64_type_test.go index 4f103525..54ae58f6 100644 --- a/types/basetypes/float64_type_test.go +++ b/types/basetypes/float64_type_test.go @@ -208,7 +208,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 } diff --git a/types/basetypes/int32_type_test.go b/types/basetypes/int32_type_test.go index af5c8bdd..04f02f4f 100644 --- a/types/basetypes/int32_type_test.go +++ b/types/basetypes/int32_type_test.go @@ -79,7 +79,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 } diff --git a/types/basetypes/int64_type_test.go b/types/basetypes/int64_type_test.go index f9986605..136bfb56 100644 --- a/types/basetypes/int64_type_test.go +++ b/types/basetypes/int64_type_test.go @@ -57,7 +57,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 } diff --git a/types/basetypes/number_type_test.go b/types/basetypes/number_type_test.go index 287e7603..ff862247 100644 --- a/types/basetypes/number_type_test.go +++ b/types/basetypes/number_type_test.go @@ -58,7 +58,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 } diff --git a/types/basetypes/string_type_test.go b/types/basetypes/string_type_test.go index 097bb89c..22634ecf 100644 --- a/types/basetypes/string_type_test.go +++ b/types/basetypes/string_type_test.go @@ -57,7 +57,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 } From 0d0f4fcfd87552e4eb0fb7f5bd36180344fc0963 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Thu, 24 Oct 2024 17:20:19 -0400 Subject: [PATCH 03/39] not null and string prefix implementations --- attr/value.go | 16 +++ go.mod | 14 +- go.sum | 26 ++-- internal/fwserver/attribute_validation.go | 64 +++++---- .../stringplanmodifier/will_have_prefix.go | 41 ++++++ .../stringplanmodifier/will_not_be_null.go | 36 ++++++ types/basetypes/string_type.go | 26 +++- types/basetypes/string_value.go | 121 +++++++++++++++++- types/refinement/doc.go | 2 + types/refinement/not_null.go | 19 +++ types/refinement/refinement.go | 46 +++++++ types/refinement/string_prefix.go | 25 ++++ 12 files changed, 388 insertions(+), 48 deletions(-) create mode 100644 resource/schema/stringplanmodifier/will_have_prefix.go create mode 100644 resource/schema/stringplanmodifier/will_not_be_null.go create mode 100644 types/refinement/doc.go create mode 100644 types/refinement/not_null.go create mode 100644 types/refinement/refinement.go create mode 100644 types/refinement/string_prefix.go diff --git a/attr/value.go b/attr/value.go index b34a3bb7..ffba78a6 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,18 @@ 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 a value refinement, if one exists, that indicates an unknown value + // will not be null once it becomes known. + NotNullRefinement() *refinement.NotNull +} + +// TODO: Should we add interfaces for all the other refinements retrieval? Even though we don't need them ATM? diff --git a/go.mod b/go.mod index 8571a329..4bb63b56 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,8 @@ go 1.22.0 toolchain go1.22.7 +replace github.com/hashicorp/terraform-plugin-go => /Users/austin.valle/code/terraform-plugin-go + require ( github.com/google/go-cmp v0.6.0 github.com/hashicorp/terraform-plugin-go v0.24.0 @@ -25,10 +27,10 @@ require ( github.com/oklog/run v1.0.0 // indirect github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect - golang.org/x/net v0.26.0 // indirect - golang.org/x/sys v0.21.0 // indirect - golang.org/x/text v0.16.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117 // indirect - google.golang.org/grpc v1.66.2 // indirect - google.golang.org/protobuf v1.34.2 // indirect + golang.org/x/net v0.28.0 // indirect + golang.org/x/sys v0.24.0 // indirect + 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 ) diff --git a/go.sum b/go.sum index 3fe9c736..5347232d 100644 --- a/go.sum +++ b/go.sum @@ -15,8 +15,6 @@ github.com/hashicorp/go-plugin v1.6.1 h1:P7MR2UP6gNKGPp+y7EZw2kOiq4IR9WiqLvp0XOs github.com/hashicorp/go-plugin v1.6.1/go.mod h1:XPHFku2tFo3o3QKFgSYo+cghcUhw1NA1hZyMK0PWAw0= 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.24.0 h1:2WpHhginCdVhFIrWHxDEg6RBn3YaWzR2o6qUeIEat2U= -github.com/hashicorp/terraform-plugin-go v0.24.0/go.mod h1:tUQ53lAsOyYSckFGEefGC5C8BAaO0ENqzFd3bQeuYQg= 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= @@ -46,23 +44,23 @@ github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IU github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= -golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= -golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= +golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= +golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= -golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= -golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117 h1:1GBuWVLM/KMVUv1t1En5Gs+gFZCNd360GGb4sSxtrhU= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117/go.mod h1:EfXuqaE1J41VCDicxHzUDm+8rk+7ZdXzHV0IhO/I6s0= -google.golang.org/grpc v1.66.2 h1:3QdXkuq3Bkh7w+ywLdLvM56cmGvQHUMZpiCzt6Rqaoo= -google.golang.org/grpc v1.66.2/go.mod h1:s3/l6xSSCURdVfAnL+TqCNMyTDAGN6+lZeVxnZR128Y= -google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= -google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= +golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= +golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 h1:e7S5W7MGGLaSu8j3YjdezkZ+m1/Nm0uRVRMEMGk26Xs= +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= 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..bc3fcb9c 100644 --- a/internal/fwserver/attribute_validation.go +++ b/internal/fwserver/attribute_validation.go @@ -137,33 +137,47 @@ 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() { + valWithNotNullRefn, ok := attributeConfig.(attr.ValueWithNotNullRefinement) + if ok { + if valWithNotNullRefn.NotNullRefinement() != nil { + 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/resource/schema/stringplanmodifier/will_have_prefix.go b/resource/schema/stringplanmodifier/will_have_prefix.go new file mode 100644 index 00000000..bd07d3cb --- /dev/null +++ b/resource/schema/stringplanmodifier/will_have_prefix.go @@ -0,0 +1,41 @@ +package stringplanmodifier + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// TODO: docs +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 will have the prefix %q once it becomes known", m.prefix) +} + +func (m willHavePrefixModifier) MarkdownDescription(_ context.Context) string { + return fmt.Sprintf("Promises the value 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_not_be_null.go b/resource/schema/stringplanmodifier/will_not_be_null.go new file mode 100644 index 00000000..1a9ffbb1 --- /dev/null +++ b/resource/schema/stringplanmodifier/will_not_be_null.go @@ -0,0 +1,36 @@ +package stringplanmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// TODO: docs +func WillNotBeNull() planmodifier.String { + return willNotBeNullModifier{} +} + +type willNotBeNullModifier struct{} + +func (m willNotBeNullModifier) Description(_ context.Context) string { + return "Promises the value will not be null once it becomes known" +} + +func (m willNotBeNullModifier) MarkdownDescription(_ context.Context) string { + return "Promises the value 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/types/basetypes/string_type.go b/types/basetypes/string_type.go index 319ae02b..5adfb88e 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" + tfrefinements "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" ) // StringTypable extends attr.Type for string types. @@ -61,7 +62,30 @@ 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 + refinements := in.Refinements() + if len(refinements) == 0 { + return NewStringUnknown(), nil + } + + unknownVal := NewStringUnknown() + for _, refn := range refinements { + switch refnVal := refn.(type) { + case tfrefinements.Nullness: + if refnVal.NotNull() { + 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 tfrefinements.StringPrefix: + unknownVal = unknownVal.RefineWithPrefix(refnVal.PrefixValue()) + } + } + + return unknownVal, nil } if in.IsNull() { diff --git a/types/basetypes/string_value.go b/types/basetypes/string_value.go index 46ac2248..c6bae0be 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" + tfrefinements "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,9 @@ type StringValue struct { // value contains the known value, if not null or unknown. value string + + // TODO: doc + refinements refinement.Refinements } // Type returns a StringType. @@ -113,7 +119,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(tfrefinements.Refinements, 0) + for _, refn := range s.refinements { + switch refnVal := refn.(type) { + case refinement.NotNull: + unknownValRefinements[tfrefinements.KeyNullness] = tfrefinements.NewNullness(false) + case refinement.StringPrefix: + unknownValRefinements[tfrefinements.KeyStringPrefix] = tfrefinements.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)) } @@ -135,6 +156,8 @@ func (s StringValue) Equal(other attr.Value) bool { return true } + // TODO: compare refinements? I might not be able to... to allow future refinements? + return s.value == o.value } @@ -155,6 +178,8 @@ func (s StringValue) IsUnknown() bool { // and is intended for logging and error reporting. func (s StringValue) String() string { if s.IsUnknown() { + // TODO: Also print out unknown value refinements? + return attr.UnknownValueString } @@ -185,3 +210,95 @@ func (s StringValue) ValueStringPointer() *string { func (s StringValue) ToStringValue(context.Context) (StringValue, diag.Diagnostics) { return s, nil } + +// RefineAsNotNull will return an unknown StringValue that includes a value refinement that: +// - Indicates the string value will not be null once it becomes known. +// +// If the StringValue is not unknown, then no refinement will be added and the provided StringValue will be returned. +func (s StringValue) RefineAsNotNull() StringValue { + // TODO: Should we return an error? + if !s.IsUnknown() { + return s + } + + // TODO: Do I need to do a full copy of this map? Do we need to copy any of this at all? Since it's operating on the value struct? + refns := make(refinement.Refinements, len(s.refinements)) + for i, refn := range s.refinements { + refns[i] = refn + } + refns[refinement.KeyNotNull] = refinement.NewNotNull() + + newUnknownVal := NewStringUnknown() + newUnknownVal.refinements = refns + + 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. +// +// If the StringValue is not unknown, then no refinement will be added and the provided StringValue will be returned. +func (s StringValue) RefineWithPrefix(prefix string) StringValue { + // TODO: Should we return an error? + if !s.IsUnknown() { + return s + } + + // TODO: Do I need to do a full copy of this map? Do we need to copy any of this at all? Since it's operating on the value struct? + refns := make(refinement.Refinements, len(s.refinements)) + for i, refn := range s.refinements { + refns[i] = refn + } + refns[refinement.KeyNotNull] = refinement.NewNotNull() + refns[refinement.KeyStringPrefix] = refinement.NewStringPrefix(prefix) + + newUnknownVal := NewStringUnknown() + newUnknownVal.refinements = refns + + return newUnknownVal +} + +// NotNullRefinement returns a value refinement, if one exists, that indicates an unknown string value +// will not be null once it becomes known. +// +// A NotNull value refinement can be added to an unknown value via the `RefineAsNotNull` method. +func (s StringValue) NotNullRefinement() *refinement.NotNull { + if !s.IsUnknown() { + return nil + } + + refn, ok := s.refinements[refinement.KeyNotNull] + if !ok { + return nil + } + + notNullRefn, ok := refn.(refinement.NotNull) + if !ok { + return nil + } + + return ¬NullRefn +} + +// PrefixRefinement returns a value refinement, if one exists, that indicates an unknown string value +// will have a specified string prefix once it becomes known. +// +// A StringPrefix value refinement can be added to an unknown value via the `RefineWithPrefix` method. +func (s StringValue) PrefixRefinement() *refinement.StringPrefix { + if !s.IsUnknown() { + return nil + } + + refn, ok := s.refinements[refinement.KeyStringPrefix] + if !ok { + return nil + } + + prefixRefn, ok := refn.(refinement.StringPrefix) + if !ok { + return nil + } + + return &prefixRefn +} diff --git a/types/refinement/doc.go b/types/refinement/doc.go new file mode 100644 index 00000000..626e0d54 --- /dev/null +++ b/types/refinement/doc.go @@ -0,0 +1,2 @@ +// TODO: doc +package refinement diff --git a/types/refinement/not_null.go b/types/refinement/not_null.go new file mode 100644 index 00000000..2d50f471 --- /dev/null +++ b/types/refinement/not_null.go @@ -0,0 +1,19 @@ +package refinement + +type NotNull struct{} + +func (n NotNull) Equal(Refinement) bool { + return false +} + +func (n NotNull) String() string { + return "todo - NotNull" +} + +func (n NotNull) unimplementable() {} + +// TODO: Should this accept a value? If a value is unknown and the it's refined to be null +// then the value should be a known value of null instead. +func NewNotNull() Refinement { + return NotNull{} +} diff --git a/types/refinement/refinement.go b/types/refinement/refinement.go new file mode 100644 index 00000000..b46a6706 --- /dev/null +++ b/types/refinement/refinement.go @@ -0,0 +1,46 @@ +package refinement + +import "fmt" + +type Key int64 + +func (k Key) String() string { + // TODO: Not sure when this is used, double check the names + switch k { + // TODO: is this the right name for it? + case KeyNotNull: + return "not_null" + case KeyStringPrefix: + return "string_prefix" + default: + return fmt.Sprintf("unsupported refinement: %d", k) + } +} + +const ( + // 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 = Key(2) + // KeyNumberLowerBound = Key(3) + // KeyNumberUpperBound = Key(4) + // KeyCollectionLengthLowerBound = Key(5) + // KeyCollectionLengthUpperBound = Key(6) +) + +type Refinement interface { + Equal(Refinement) bool + String() string + unimplementable() // prevents external implementations, all refinements are defined in the Terraform/HCL type system go-cty. +} + +type Refinements map[Key]Refinement + +func (r Refinements) Equal(o Refinements) bool { + return false +} +func (r Refinements) String() string { + // TODO: Not sure when this is used, should just aggregate and call all underlying refinements.String() method + return "todo" +} diff --git a/types/refinement/string_prefix.go b/types/refinement/string_prefix.go new file mode 100644 index 00000000..935af91b --- /dev/null +++ b/types/refinement/string_prefix.go @@ -0,0 +1,25 @@ +package refinement + +type StringPrefix struct { + value string +} + +func (s StringPrefix) Equal(Refinement) bool { + return false +} + +func (s StringPrefix) String() string { + return "todo - stringPrefix" +} + +func (s StringPrefix) PrefixValue() string { + return s.value +} + +func (s StringPrefix) unimplementable() {} + +func NewStringPrefix(value string) Refinement { + return StringPrefix{ + value: value, + } +} From 15121c4f50f86f48de3221aa55e92c3246812a63 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Fri, 25 Oct 2024 17:58:57 -0400 Subject: [PATCH 04/39] add number bounds --- .../int64planmodifier/will_be_at_least.go | 41 +++++ .../int64planmodifier/will_be_at_most.go | 41 +++++ .../int64planmodifier/will_be_between.go | 45 +++++ .../int64planmodifier/will_not_be_null.go | 36 ++++ types/basetypes/int64_type.go | 40 ++++- types/basetypes/int64_value.go | 170 +++++++++++++++++- types/basetypes/string_type.go | 2 +- types/refinement/number_lower_bound.go | 33 ++++ types/refinement/number_upper_bound.go | 33 ++++ types/refinement/refinement.go | 12 +- 10 files changed, 445 insertions(+), 8 deletions(-) create mode 100644 resource/schema/int64planmodifier/will_be_at_least.go create mode 100644 resource/schema/int64planmodifier/will_be_at_most.go create mode 100644 resource/schema/int64planmodifier/will_be_between.go create mode 100644 resource/schema/int64planmodifier/will_not_be_null.go create mode 100644 types/refinement/number_lower_bound.go create mode 100644 types/refinement/number_upper_bound.go 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..36228c54 --- /dev/null +++ b/resource/schema/int64planmodifier/will_be_at_least.go @@ -0,0 +1,41 @@ +package int64planmodifier + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// TODO: docs +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 will be at least %d once it becomes known", m.min) +} + +func (m willBeAtLeastModifier) MarkdownDescription(_ context.Context) string { + return fmt.Sprintf("Promises the value 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_most.go b/resource/schema/int64planmodifier/will_be_at_most.go new file mode 100644 index 00000000..32657275 --- /dev/null +++ b/resource/schema/int64planmodifier/will_be_at_most.go @@ -0,0 +1,41 @@ +package int64planmodifier + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// TODO: docs +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 will be at most %d once it becomes known", m.max) +} + +func (m willBeAtMostModifier) MarkdownDescription(_ context.Context) string { + return fmt.Sprintf("Promises the value 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_between.go b/resource/schema/int64planmodifier/will_be_between.go new file mode 100644 index 00000000..04c18cc9 --- /dev/null +++ b/resource/schema/int64planmodifier/will_be_between.go @@ -0,0 +1,45 @@ +package int64planmodifier + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// TODO: docs +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 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 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_not_be_null.go b/resource/schema/int64planmodifier/will_not_be_null.go new file mode 100644 index 00000000..9e92ecfb --- /dev/null +++ b/resource/schema/int64planmodifier/will_not_be_null.go @@ -0,0 +1,36 @@ +package int64planmodifier + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" +) + +// TODO: docs +func WillNotBeNull() planmodifier.Int64 { + return willNotBeNullModifier{} +} + +type willNotBeNullModifier struct{} + +func (m willNotBeNullModifier) Description(_ context.Context) string { + return "Promises the value will not be null once it becomes known" +} + +func (m willNotBeNullModifier) MarkdownDescription(_ context.Context) string { + return "Promises the value 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/types/basetypes/int64_type.go b/types/basetypes/int64_type.go index 93fa1b9e..a4a8ebd7 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" + tfrefinements "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,44 @@ 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 + refinements := in.Refinements() + if len(refinements) == 0 { + return NewInt64Unknown(), nil + } + + unknownVal := NewInt64Unknown() + for _, refn := range refinements { + switch refnVal := refn.(type) { + case tfrefinements.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 tfrefinements.NumberLowerBound: + // TODO: I don't think this is safe, but not sure what the expectation should be? + // Should I just chop the decimal off? + // Could also just directly create the refinement here, rather than using the int64 facing one? + // TODO: use-case, resource A sets an unknown value refinement with float, resource B receives this refinement + // and chops the decimal point off, thus changing the refinement, which is invalid. + boundVal, _ := refnVal.LowerBound().Int64() + unknownVal = unknownVal.RefineWithLowerBound(boundVal, refnVal.IsInclusive()) + case tfrefinements.NumberUpperBound: + // TODO: I don't think this is safe, but not sure what the expectation should be? + // Should I just chop the decimal off? + // Could also just directly create the refinement here, rather than using the int64 facing one? + // TODO: use-case, resource A sets an unknown value refinement with float, resource B receives this refinement + // and chops the decimal point off, thus changing the refinement, which is invalid. + boundVal, _ := refnVal.UpperBound().Int64() + unknownVal = unknownVal.RefineWithUpperBound(boundVal, refnVal.IsInclusive()) + } + } + + return unknownVal, nil } if in.IsNull() { diff --git a/types/basetypes/int64_value.go b/types/basetypes/int64_value.go index bf8b3bd5..f8a928bb 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" + tfrefinements "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,9 @@ type Int64Value struct { // value contains the known value, if not null or unknown. value int64 + + // TODO: doc + refinements refinement.Refinements } // Equal returns true if `other` is an Int64 and has the same value as `i`. @@ -102,6 +109,8 @@ func (i Int64Value) Equal(other attr.Value) bool { return true } + // TODO: compare refinements? I might not be able to... to allow future refinements? + return i.value == o.value } @@ -117,7 +126,24 @@ 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(tfrefinements.Refinements, 0) + for _, refn := range i.refinements { + switch refnVal := refn.(type) { + case refinement.NotNull: + unknownValRefinements[tfrefinements.KeyNullness] = tfrefinements.NewNullness(false) + case refinement.NumberLowerBound: + unknownValRefinements[tfrefinements.KeyNumberLowerBound] = tfrefinements.NewNumberLowerBound(refnVal.LowerBound(), refnVal.IsInclusive()) + case refinement.NumberUpperBound: + unknownValRefinements[tfrefinements.KeyNumberUpperBound] = tfrefinements.NewNumberUpperBound(refnVal.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,6 +169,7 @@ func (i Int64Value) IsUnknown() bool { // and is intended for logging and error reporting. func (i Int64Value) String() string { if i.IsUnknown() { + // TODO: Also print out unknown value refinements? return attr.UnknownValueString } @@ -173,3 +200,142 @@ 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 Int64Value is not unknown, then no refinement will be added and the provided Int64Value will be returned. +func (i Int64Value) RefineAsNotNull() Int64Value { + // TODO: Should we return an error? + if !i.IsUnknown() { + return i + } + + // TODO: Do I need to do a full copy of this map? Do we need to copy any of this at all? Since it's operating on the value struct? + refns := make(refinement.Refinements, len(i.refinements)) + for i, refn := range i.refinements { + refns[i] = refn + } + refns[refinement.KeyNotNull] = refinement.NewNotNull() + + newUnknownVal := NewInt64Unknown() + newUnknownVal.refinements = refns + + 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 Int64Value is not unknown, then no refinement will be added and the provided Int64Value will be returned. +func (i Int64Value) RefineWithLowerBound(lowerBound int64, inclusive bool) Int64Value { + // TODO: Should we return an error? + if !i.IsUnknown() { + return i + } + + // TODO: Do I need to do a full copy of this map? Do we need to copy any of this at all? Since it's operating on the value struct? + refns := make(refinement.Refinements, len(i.refinements)) + for i, refn := range i.refinements { + refns[i] = refn + } + refns[refinement.KeyNotNull] = refinement.NewNotNull() + refns[refinement.KeyNumberLowerBound] = refinement.NewNumberLowerBound(new(big.Float).SetInt64(lowerBound), inclusive) + + newUnknownVal := NewInt64Unknown() + newUnknownVal.refinements = refns + + 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 Int64Value is not unknown, then no refinement will be added and the provided Int64Value will be returned. +func (i Int64Value) RefineWithUpperBound(upperBound int64, inclusive bool) Int64Value { + // TODO: Should we return an error? + if !i.IsUnknown() { + return i + } + + // TODO: Do I need to do a full copy of this map? Do we need to copy any of this at all? Since it's operating on the value struct? + refns := make(refinement.Refinements, len(i.refinements)) + for i, refn := range i.refinements { + refns[i] = refn + } + refns[refinement.KeyNotNull] = refinement.NewNotNull() + refns[refinement.KeyNumberUpperBound] = refinement.NewNumberUpperBound(new(big.Float).SetInt64(upperBound), inclusive) + + newUnknownVal := NewInt64Unknown() + newUnknownVal.refinements = refns + + return newUnknownVal +} + +// NotNullRefinement returns a value refinement, if one exists, that indicates an unknown int64 value +// will not be null once it becomes known. +// +// A NotNull value refinement can be added to an unknown value via the `RefineAsNotNull` method. +func (i Int64Value) NotNullRefinement() *refinement.NotNull { + if !i.IsUnknown() { + return nil + } + + refn, ok := i.refinements[refinement.KeyNotNull] + if !ok { + return nil + } + + notNullRefn, ok := refn.(refinement.NotNull) + if !ok { + return nil + } + + return ¬NullRefn +} + +// LowerBoundRefinement returns a value refinement, if one exists, that indicates an unknown int64 value +// will not be less than the specified int64 value once it becomes known. +// +// A NumberLowerBound value refinement can be added to an unknown value via the `RefineWithLowerBound` method. +func (i Int64Value) LowerBoundRefinement() *refinement.NumberLowerBound { + if !i.IsUnknown() { + return nil + } + + refn, ok := i.refinements[refinement.KeyNumberLowerBound] + if !ok { + return nil + } + + lowerBoundRefn, ok := refn.(refinement.NumberLowerBound) + if !ok { + return nil + } + + return &lowerBoundRefn +} + +// UpperBoundRefinement returns a value refinement, if one exists, that indicates an unknown int64 value +// will not be greater than the specified int64 value once it becomes known. +// +// A NumberUpperBound value refinement can be added to an unknown value via the `RefineWithUpperBound` method. +func (i Int64Value) UpperBoundRefinement() *refinement.NumberUpperBound { + if !i.IsUnknown() { + return nil + } + + refn, ok := i.refinements[refinement.KeyNumberUpperBound] + if !ok { + return nil + } + + upperBoundRefn, ok := refn.(refinement.NumberUpperBound) + if !ok { + return nil + } + + return &upperBoundRefn +} diff --git a/types/basetypes/string_type.go b/types/basetypes/string_type.go index 5adfb88e..8828e0c7 100644 --- a/types/basetypes/string_type.go +++ b/types/basetypes/string_type.go @@ -71,7 +71,7 @@ func (t StringType) ValueFromTerraform(ctx context.Context, in tftypes.Value) (a for _, refn := range refinements { switch refnVal := refn.(type) { case tfrefinements.Nullness: - if refnVal.NotNull() { + if !refnVal.Nullness() { unknownVal = unknownVal.RefineAsNotNull() } else { // This scenario shouldn't occur, as Terraform should have already collapsed an diff --git a/types/refinement/number_lower_bound.go b/types/refinement/number_lower_bound.go new file mode 100644 index 00000000..ff051027 --- /dev/null +++ b/types/refinement/number_lower_bound.go @@ -0,0 +1,33 @@ +package refinement + +import "math/big" + +type NumberLowerBound struct { + inclusive bool + value *big.Float +} + +func (s NumberLowerBound) Equal(Refinement) bool { + return false +} + +func (s NumberLowerBound) String() string { + return "todo - NumberLowerBound" +} + +func (s NumberLowerBound) IsInclusive() bool { + return s.inclusive +} + +func (s NumberLowerBound) LowerBound() *big.Float { + return s.value +} + +func (s NumberLowerBound) unimplementable() {} + +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..e18a7b93 --- /dev/null +++ b/types/refinement/number_upper_bound.go @@ -0,0 +1,33 @@ +package refinement + +import "math/big" + +type NumberUpperBound struct { + inclusive bool + value *big.Float +} + +func (s NumberUpperBound) Equal(Refinement) bool { + return false +} + +func (s NumberUpperBound) String() string { + return "todo - NumberUpperBound" +} + +func (s NumberUpperBound) IsInclusive() bool { + return s.inclusive +} + +func (s NumberUpperBound) UpperBound() *big.Float { + return s.value +} + +func (s NumberUpperBound) unimplementable() {} + +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 index b46a6706..d0f71f2e 100644 --- a/types/refinement/refinement.go +++ b/types/refinement/refinement.go @@ -12,6 +12,10 @@ func (k Key) String() string { return "not_null" case KeyStringPrefix: return "string_prefix" + case KeyNumberLowerBound: + return "number_lower_bound" + case KeyNumberUpperBound: + return "number_upper_bound" default: return fmt.Sprintf("unsupported refinement: %d", k) } @@ -21,10 +25,10 @@ const ( // 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 = Key(2) - // KeyNumberLowerBound = Key(3) - // KeyNumberUpperBound = Key(4) + KeyNotNull = Key(1) + KeyStringPrefix = Key(2) + KeyNumberLowerBound = Key(3) + KeyNumberUpperBound = Key(4) // KeyCollectionLengthLowerBound = Key(5) // KeyCollectionLengthUpperBound = Key(6) ) From dfdef531b75709b04dab682f87a783b2295f1fff Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Tue, 5 Nov 2024 08:04:16 -0500 Subject: [PATCH 05/39] switch back to local - go mod tidy --- go.sum | 2 -- 1 file changed, 2 deletions(-) diff --git a/go.sum b/go.sum index 99e7874b..7dd420ec 100644 --- a/go.sum +++ b/go.sum @@ -15,8 +15,6 @@ 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-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= From dcd19d99ec607da840c325077cb3eeff8817bd5c Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Thu, 21 Nov 2024 12:18:25 -0500 Subject: [PATCH 06/39] switched up interface --- attr/value.go | 9 ++- internal/fwserver/attribute_validation.go | 8 ++- types/basetypes/int64_value.go | 70 +++++++++++++---------- types/basetypes/string_value.go | 33 ++++++----- types/refinement/int64_lower_bound.go | 31 ++++++++++ types/refinement/int64_upper_bound.go | 31 ++++++++++ types/refinement/refinement.go | 19 +++++- 7 files changed, 145 insertions(+), 56 deletions(-) create mode 100644 types/refinement/int64_lower_bound.go create mode 100644 types/refinement/int64_upper_bound.go diff --git a/attr/value.go b/attr/value.go index ffba78a6..7e8793fe 100644 --- a/attr/value.go +++ b/attr/value.go @@ -79,9 +79,8 @@ type Value interface { type ValueWithNotNullRefinement interface { Value - // NotNullRefinement returns a value refinement, if one exists, that indicates an unknown value - // will not be null once it becomes known. - NotNullRefinement() *refinement.NotNull + // 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) } - -// TODO: Should we add interfaces for all the other refinements retrieval? Even though we don't need them ATM? diff --git a/internal/fwserver/attribute_validation.go b/internal/fwserver/attribute_validation.go index bc3fcb9c..bd24643a 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. @@ -140,9 +140,11 @@ func AttributeValidate(ctx context.Context, a fwschema.Attribute, req ValidateAt // Show deprecation warnings only for known values or unknown values with a "not null" refinement. if a.GetDeprecationMessage() != "" { if attributeConfig.IsUnknown() { - valWithNotNullRefn, ok := attributeConfig.(attr.ValueWithNotNullRefinement) + // If the attr.Value supports checking for refinements, we should check if the eventual known value will be not null. + val, ok := attributeConfig.(attr.ValueWithNotNullRefinement) if ok { - if valWithNotNullRefn.NotNullRefinement() != nil { + if _, notNull := val.NotNullRefinement(); notNull { + // If the unknown value will eventually be not null, we return the deprecation message for the practitioner. resp.Diagnostics.AddAttributeWarning( req.AttributePath, "Attribute Deprecated", diff --git a/types/basetypes/int64_value.go b/types/basetypes/int64_value.go index f8a928bb..ef8263db 100644 --- a/types/basetypes/int64_value.go +++ b/types/basetypes/int64_value.go @@ -135,10 +135,11 @@ func (i Int64Value) ToTerraformValue(ctx context.Context) (tftypes.Value, error) switch refnVal := refn.(type) { case refinement.NotNull: unknownValRefinements[tfrefinements.KeyNullness] = tfrefinements.NewNullness(false) - case refinement.NumberLowerBound: - unknownValRefinements[tfrefinements.KeyNumberLowerBound] = tfrefinements.NewNumberLowerBound(refnVal.LowerBound(), refnVal.IsInclusive()) - case refinement.NumberUpperBound: - unknownValRefinements[tfrefinements.KeyNumberUpperBound] = tfrefinements.NewNumberUpperBound(refnVal.UpperBound(), refnVal.IsInclusive()) + case refinement.Int64LowerBound: + // TODO: is int64 to big.NewFloat safe? I think it is... + unknownValRefinements[tfrefinements.KeyNumberLowerBound] = tfrefinements.NewNumberLowerBound(big.NewFloat(float64(refnVal.LowerBound())), refnVal.IsInclusive()) + case refinement.Int64UpperBound: + unknownValRefinements[tfrefinements.KeyNumberUpperBound] = tfrefinements.NewNumberUpperBound(big.NewFloat(float64(refnVal.UpperBound())), refnVal.IsInclusive()) } } unknownVal := tftypes.NewValue(tftypes.Number, tftypes.UnknownValue) @@ -241,7 +242,7 @@ func (i Int64Value) RefineWithLowerBound(lowerBound int64, inclusive bool) Int64 refns[i] = refn } refns[refinement.KeyNotNull] = refinement.NewNotNull() - refns[refinement.KeyNumberLowerBound] = refinement.NewNumberLowerBound(new(big.Float).SetInt64(lowerBound), inclusive) + refns[refinement.KeyNumberLowerBound] = refinement.NewInt64LowerBound(lowerBound, inclusive) newUnknownVal := NewInt64Unknown() newUnknownVal.refinements = refns @@ -266,7 +267,7 @@ func (i Int64Value) RefineWithUpperBound(upperBound int64, inclusive bool) Int64 refns[i] = refn } refns[refinement.KeyNotNull] = refinement.NewNotNull() - refns[refinement.KeyNumberUpperBound] = refinement.NewNumberUpperBound(new(big.Float).SetInt64(upperBound), inclusive) + refns[refinement.KeyNumberUpperBound] = refinement.NewInt64UpperBound(upperBound, inclusive) newUnknownVal := NewInt64Unknown() newUnknownVal.refinements = refns @@ -274,68 +275,75 @@ func (i Int64Value) RefineWithUpperBound(upperBound int64, inclusive bool) Int64 return newUnknownVal } -// NotNullRefinement returns a value refinement, if one exists, that indicates an unknown int64 value -// will not be null once it becomes known. +// 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 { +func (i Int64Value) NotNullRefinement() (*refinement.NotNull, bool) { if !i.IsUnknown() { - return nil + return nil, false } refn, ok := i.refinements[refinement.KeyNotNull] if !ok { - return nil + return nil, false } notNullRefn, ok := refn.(refinement.NotNull) if !ok { - return nil + return nil, false } - return ¬NullRefn + return ¬NullRefn, true } -// LowerBoundRefinement returns a value refinement, if one exists, that indicates an unknown int64 value -// will not be less than the specified int64 value once it becomes known. +// 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. // -// A NumberLowerBound value refinement can be added to an unknown value via the `RefineWithLowerBound` method. -func (i Int64Value) LowerBoundRefinement() *refinement.NumberLowerBound { +// 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 + return nil, false } refn, ok := i.refinements[refinement.KeyNumberLowerBound] if !ok { - return nil + return nil, false } - lowerBoundRefn, ok := refn.(refinement.NumberLowerBound) + lowerBoundRefn, ok := refn.(refinement.Int64LowerBound) if !ok { - return nil + return nil, false } - return &lowerBoundRefn + return &lowerBoundRefn, true } -// UpperBoundRefinement returns a value refinement, if one exists, that indicates an unknown int64 value -// will not be greater than the specified int64 value once it becomes known. +// 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 NumberUpperBound value refinement can be added to an unknown value via the `RefineWithUpperBound` method. -func (i Int64Value) UpperBoundRefinement() *refinement.NumberUpperBound { +// 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 + return nil, false } refn, ok := i.refinements[refinement.KeyNumberUpperBound] if !ok { - return nil + return nil, false } - upperBoundRefn, ok := refn.(refinement.NumberUpperBound) + upperBoundRefn, ok := refn.(refinement.Int64UpperBound) if !ok { - return nil + return nil, false } - return &upperBoundRefn + return &upperBoundRefn, true } diff --git a/types/basetypes/string_value.go b/types/basetypes/string_value.go index c6bae0be..cf2b8988 100644 --- a/types/basetypes/string_value.go +++ b/types/basetypes/string_value.go @@ -238,7 +238,7 @@ func (s StringValue) RefineAsNotNull() StringValue { // - 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. // -// If the StringValue is not unknown, then no refinement will be added and the provided StringValue will be returned. +// If the StringValue is not unknown, then the provided StringValue will be returned without changes. func (s StringValue) RefineWithPrefix(prefix string) StringValue { // TODO: Should we return an error? if !s.IsUnknown() { @@ -259,46 +259,49 @@ func (s StringValue) RefineWithPrefix(prefix string) StringValue { return newUnknownVal } -// NotNullRefinement returns a value refinement, if one exists, that indicates an unknown string value -// will not be null once it becomes known. +// 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 { +func (s StringValue) NotNullRefinement() (*refinement.NotNull, bool) { if !s.IsUnknown() { - return nil + return nil, false } refn, ok := s.refinements[refinement.KeyNotNull] if !ok { - return nil + return nil, false } notNullRefn, ok := refn.(refinement.NotNull) if !ok { - return nil + return nil, false } - return ¬NullRefn + return ¬NullRefn, true } -// PrefixRefinement returns a value refinement, if one exists, that indicates an unknown string value -// will have a specified string prefix once it becomes known. +// 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 { +func (s StringValue) PrefixRefinement() (*refinement.StringPrefix, bool) { if !s.IsUnknown() { - return nil + return nil, false } refn, ok := s.refinements[refinement.KeyStringPrefix] if !ok { - return nil + return nil, false } prefixRefn, ok := refn.(refinement.StringPrefix) if !ok { - return nil + return nil, false } - return &prefixRefn + return &prefixRefn, true } diff --git a/types/refinement/int64_lower_bound.go b/types/refinement/int64_lower_bound.go new file mode 100644 index 00000000..4bb7fb33 --- /dev/null +++ b/types/refinement/int64_lower_bound.go @@ -0,0 +1,31 @@ +package refinement + +type Int64LowerBound struct { + inclusive bool + value int64 +} + +func (s Int64LowerBound) Equal(Refinement) bool { + return false +} + +func (s Int64LowerBound) String() string { + return "todo - Int64LowerBound" +} + +func (s Int64LowerBound) IsInclusive() bool { + return s.inclusive +} + +func (s Int64LowerBound) LowerBound() int64 { + return s.value +} + +func (s Int64LowerBound) unimplementable() {} + +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..8f1fbf65 --- /dev/null +++ b/types/refinement/int64_upper_bound.go @@ -0,0 +1,31 @@ +package refinement + +type Int64UpperBound struct { + inclusive bool + value int64 +} + +func (s Int64UpperBound) Equal(Refinement) bool { + return false +} + +func (s Int64UpperBound) String() string { + return "todo - Int64UpperBound" +} + +func (s Int64UpperBound) IsInclusive() bool { + return s.inclusive +} + +func (s Int64UpperBound) UpperBound() int64 { + return s.value +} + +func (s Int64UpperBound) unimplementable() {} + +func NewInt64UpperBound(value int64, inclusive bool) Refinement { + return Int64UpperBound{ + value: value, + inclusive: inclusive, + } +} diff --git a/types/refinement/refinement.go b/types/refinement/refinement.go index d0f71f2e..bd825eb0 100644 --- a/types/refinement/refinement.go +++ b/types/refinement/refinement.go @@ -25,10 +25,25 @@ const ( // 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 = Key(2) + KeyNotNull = Key(1) + KeyStringPrefix = Key(2) + + // Key is shared between: + // - Int64LowerBound + // - Int32LowerBound + // - Float64LowerBound + // - Float32LowerBound + // - NumberLowerBound KeyNumberLowerBound = Key(3) + + // Key is shared between: + // - Int64UpperBound + // - Int32UpperBound + // - Float64UpperBound + // - Float32UpperBound + // - NumberUpperBound KeyNumberUpperBound = Key(4) + // KeyCollectionLengthLowerBound = Key(5) // KeyCollectionLengthUpperBound = Key(6) ) From 31d81add263f4a4e22ab8537a03444b55f85d624 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Tue, 26 Nov 2024 10:06:12 -0500 Subject: [PATCH 07/39] bump versions --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 834b106e..c3686a6a 100644 --- a/go.mod +++ b/go.mod @@ -32,5 +32,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 7dd420ec..2bd0287d 100644 --- a/go.sum +++ b/go.sum @@ -62,8 +62,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= From f297ccee0136bb630b0c17de82987c16caba7d54 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Tue, 26 Nov 2024 13:31:05 -0500 Subject: [PATCH 08/39] update existing docs + equal/string methods --- .../int64planmodifier/will_be_at_least.go | 3 + .../int64planmodifier/will_be_at_most.go | 3 + .../int64planmodifier/will_be_between.go | 3 + .../int64planmodifier/will_not_be_null.go | 3 + .../stringplanmodifier/will_have_prefix.go | 3 + .../stringplanmodifier/will_not_be_null.go | 3 + types/refinement/doc.go | 11 +- types/refinement/int64_lower_bound.go | 40 ++++-- types/refinement/int64_upper_bound.go | 40 ++++-- types/refinement/not_null.go | 16 ++- types/refinement/number_lower_bound.go | 43 +++++-- types/refinement/number_upper_bound.go | 43 +++++-- types/refinement/refinement.go | 117 ++++++++++++++---- types/refinement/string_prefix.go | 23 +++- 14 files changed, 282 insertions(+), 69 deletions(-) diff --git a/resource/schema/int64planmodifier/will_be_at_least.go b/resource/schema/int64planmodifier/will_be_at_least.go index 36228c54..1c50c218 100644 --- a/resource/schema/int64planmodifier/will_be_at_least.go +++ b/resource/schema/int64planmodifier/will_be_at_least.go @@ -1,3 +1,6 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + package int64planmodifier import ( diff --git a/resource/schema/int64planmodifier/will_be_at_most.go b/resource/schema/int64planmodifier/will_be_at_most.go index 32657275..f142c8ad 100644 --- a/resource/schema/int64planmodifier/will_be_at_most.go +++ b/resource/schema/int64planmodifier/will_be_at_most.go @@ -1,3 +1,6 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + package int64planmodifier import ( diff --git a/resource/schema/int64planmodifier/will_be_between.go b/resource/schema/int64planmodifier/will_be_between.go index 04c18cc9..b3768a17 100644 --- a/resource/schema/int64planmodifier/will_be_between.go +++ b/resource/schema/int64planmodifier/will_be_between.go @@ -1,3 +1,6 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + package int64planmodifier import ( diff --git a/resource/schema/int64planmodifier/will_not_be_null.go b/resource/schema/int64planmodifier/will_not_be_null.go index 9e92ecfb..a5a35888 100644 --- a/resource/schema/int64planmodifier/will_not_be_null.go +++ b/resource/schema/int64planmodifier/will_not_be_null.go @@ -1,3 +1,6 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + package int64planmodifier import ( diff --git a/resource/schema/stringplanmodifier/will_have_prefix.go b/resource/schema/stringplanmodifier/will_have_prefix.go index bd07d3cb..0c2bf6c0 100644 --- a/resource/schema/stringplanmodifier/will_have_prefix.go +++ b/resource/schema/stringplanmodifier/will_have_prefix.go @@ -1,3 +1,6 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + package stringplanmodifier import ( diff --git a/resource/schema/stringplanmodifier/will_not_be_null.go b/resource/schema/stringplanmodifier/will_not_be_null.go index 1a9ffbb1..917ded30 100644 --- a/resource/schema/stringplanmodifier/will_not_be_null.go +++ b/resource/schema/stringplanmodifier/will_not_be_null.go @@ -1,3 +1,6 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + package stringplanmodifier import ( diff --git a/types/refinement/doc.go b/types/refinement/doc.go index 626e0d54..84ffb016 100644 --- a/types/refinement/doc.go +++ b/types/refinement/doc.go @@ -1,2 +1,11 @@ -// TODO: doc +// 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/int64_lower_bound.go b/types/refinement/int64_lower_bound.go index 4bb7fb33..dca9efca 100644 --- a/types/refinement/int64_lower_bound.go +++ b/types/refinement/int64_lower_bound.go @@ -1,28 +1,50 @@ +// 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 (s Int64LowerBound) Equal(Refinement) bool { - return false +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 (s Int64LowerBound) String() string { - return "todo - Int64LowerBound" +func (i Int64LowerBound) String() string { + rangeDescription := "inclusive" + if !i.IsInclusive() { + rangeDescription = "exclusive" + } + + return fmt.Sprintf("lower bound = %d (%s)", i.LowerBound(), rangeDescription) } -func (s Int64LowerBound) IsInclusive() bool { - return s.inclusive +// IsInclusive returns whether the bound returned by the `LowerBound` method is inclusive or exclusive. +func (i Int64LowerBound) IsInclusive() bool { + return i.inclusive } -func (s Int64LowerBound) LowerBound() int64 { - return s.value +// 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 (s Int64LowerBound) unimplementable() {} +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, diff --git a/types/refinement/int64_upper_bound.go b/types/refinement/int64_upper_bound.go index 8f1fbf65..b243ce6f 100644 --- a/types/refinement/int64_upper_bound.go +++ b/types/refinement/int64_upper_bound.go @@ -1,28 +1,50 @@ +// 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 (s Int64UpperBound) Equal(Refinement) bool { - return false +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 (s Int64UpperBound) String() string { - return "todo - Int64UpperBound" +func (i Int64UpperBound) String() string { + rangeDescription := "inclusive" + if !i.IsInclusive() { + rangeDescription = "exclusive" + } + + return fmt.Sprintf("upper bound = %d (%s)", i.UpperBound(), rangeDescription) } -func (s Int64UpperBound) IsInclusive() bool { - return s.inclusive +// IsInclusive returns whether the bound returned by the `UpperBound` method is inclusive or exclusive. +func (i Int64UpperBound) IsInclusive() bool { + return i.inclusive } -func (s Int64UpperBound) UpperBound() int64 { - return s.value +// 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 (s Int64UpperBound) unimplementable() {} +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, diff --git a/types/refinement/not_null.go b/types/refinement/not_null.go index 2d50f471..95ac755c 100644 --- a/types/refinement/not_null.go +++ b/types/refinement/not_null.go @@ -1,19 +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(Refinement) bool { - return false +func (n NotNull) Equal(other Refinement) bool { + _, refnMatches := other.(NotNull) + return refnMatches } func (n NotNull) String() string { - return "todo - NotNull" + return "not null" } func (n NotNull) unimplementable() {} -// TODO: Should this accept a value? If a value is unknown and the it's refined to be null -// then the value should be a known value of null instead. +// 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 index ff051027..8a143fc7 100644 --- a/types/refinement/number_lower_bound.go +++ b/types/refinement/number_lower_bound.go @@ -1,30 +1,53 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + package refinement -import "math/big" +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 (s NumberLowerBound) Equal(Refinement) bool { - return false +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 (s NumberLowerBound) String() string { - return "todo - NumberLowerBound" +func (n NumberLowerBound) String() string { + rangeDescription := "inclusive" + if !n.IsInclusive() { + rangeDescription = "exclusive" + } + + return fmt.Sprintf("lower bound = %s (%s)", n.LowerBound().String(), rangeDescription) } -func (s NumberLowerBound) IsInclusive() bool { - return s.inclusive +// IsInclusive returns whether the bound returned by the `LowerBound` method is inclusive or exclusive. +func (n NumberLowerBound) IsInclusive() bool { + return n.inclusive } -func (s NumberLowerBound) LowerBound() *big.Float { - return s.value +// 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 (s NumberLowerBound) unimplementable() {} +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, diff --git a/types/refinement/number_upper_bound.go b/types/refinement/number_upper_bound.go index e18a7b93..fdadbd29 100644 --- a/types/refinement/number_upper_bound.go +++ b/types/refinement/number_upper_bound.go @@ -1,30 +1,53 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + package refinement -import "math/big" +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 (s NumberUpperBound) Equal(Refinement) bool { - return false +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 (s NumberUpperBound) String() string { - return "todo - NumberUpperBound" +func (n NumberUpperBound) String() string { + rangeDescription := "inclusive" + if !n.IsInclusive() { + rangeDescription = "exclusive" + } + + return fmt.Sprintf("upper bound = %s (%s)", n.UpperBound().String(), rangeDescription) } -func (s NumberUpperBound) IsInclusive() bool { - return s.inclusive +// IsInclusive returns whether the bound returned by the `UpperBound` method is inclusive or exclusive. +func (n NumberUpperBound) IsInclusive() bool { + return n.inclusive } -func (s NumberUpperBound) UpperBound() *big.Float { - return s.value +// 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 (s NumberUpperBound) unimplementable() {} +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, diff --git a/types/refinement/refinement.go b/types/refinement/refinement.go index bd825eb0..b423aa3f 100644 --- a/types/refinement/refinement.go +++ b/types/refinement/refinement.go @@ -1,13 +1,18 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + package refinement -import "fmt" +import ( + "fmt" + "sort" + "strings" +) type Key int64 func (k Key) String() string { - // TODO: Not sure when this is used, double check the names switch k { - // TODO: is this the right name for it? case KeyNotNull: return "not_null" case KeyStringPrefix: @@ -16,50 +21,118 @@ func (k Key) String() string { 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) + 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) - // Key is shared between: - // - Int64LowerBound - // - Int32LowerBound - // - Float64LowerBound - // - Float32LowerBound - // - NumberLowerBound + // 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: + // - Int64LowerBound + // - Int32LowerBound + // - Float64LowerBound + // - Float32LowerBound + // - NumberLowerBound KeyNumberLowerBound = Key(3) - // Key is shared between: - // - Int64UpperBound - // - Int32UpperBound - // - Float64UpperBound - // - Float32UpperBound - // - NumberUpperBound + // 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: + // - Int64UpperBound + // - Int32UpperBound + // - Float64UpperBound + // - Float32UpperBound + // - NumberUpperBound KeyNumberUpperBound = Key(4) - // KeyCollectionLengthLowerBound = Key(5) - // KeyCollectionLengthUpperBound = Key(6) + // 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(o Refinements) bool { - return false +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 { - // TODO: Not sure when this is used, should just aggregate and call all underlying refinements.String() method - return "todo" + 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 index 935af91b..5852262d 100644 --- a/types/refinement/string_prefix.go +++ b/types/refinement/string_prefix.go @@ -1,23 +1,40 @@ +// 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(Refinement) bool { - return false +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 "todo - stringPrefix" + 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, From 4de17f2dc923ebe57f3be2d9fde90c3c9d87dc9c Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Tue, 26 Nov 2024 13:44:42 -0500 Subject: [PATCH 09/39] new refinements --- .../collection_length_lower_bound.go | 40 ++++++++++++++ .../collection_length_upper_bound.go | 40 ++++++++++++++ types/refinement/float32_lower_bound.go | 53 +++++++++++++++++++ types/refinement/float32_upper_bound.go | 53 +++++++++++++++++++ types/refinement/float64_lower_bound.go | 53 +++++++++++++++++++ types/refinement/float64_upper_bound.go | 53 +++++++++++++++++++ types/refinement/int32_lower_bound.go | 53 +++++++++++++++++++ types/refinement/int32_upper_bound.go | 53 +++++++++++++++++++ 8 files changed, 398 insertions(+) create mode 100644 types/refinement/collection_length_lower_bound.go create mode 100644 types/refinement/collection_length_upper_bound.go create mode 100644 types/refinement/float32_lower_bound.go create mode 100644 types/refinement/float32_upper_bound.go create mode 100644 types/refinement/float64_lower_bound.go create mode 100644 types/refinement/float64_upper_bound.go create mode 100644 types/refinement/int32_lower_bound.go create mode 100644 types/refinement/int32_upper_bound.go 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/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, + } +} From 0b35094bc7421f0441b6412375bdff84100044ef Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Tue, 26 Nov 2024 15:22:11 -0500 Subject: [PATCH 10/39] var name --- types/refinement/not_null.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/types/refinement/not_null.go b/types/refinement/not_null.go index 95ac755c..0122f43e 100644 --- a/types/refinement/not_null.go +++ b/types/refinement/not_null.go @@ -8,8 +8,8 @@ package refinement type NotNull struct{} func (n NotNull) Equal(other Refinement) bool { - _, refnMatches := other.(NotNull) - return refnMatches + _, ok := other.(NotNull) + return ok } func (n NotNull) String() string { From 53802ef5fa6e875d25d6f6926229338b377a3de2 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Tue, 26 Nov 2024 17:08:24 -0500 Subject: [PATCH 11/39] update string type and value --- types/basetypes/string_type.go | 11 +++--- types/basetypes/string_value.go | 63 ++++++++++++++++++++------------- 2 files changed, 44 insertions(+), 30 deletions(-) diff --git a/types/basetypes/string_type.go b/types/basetypes/string_type.go index 8828e0c7..7102dd65 100644 --- a/types/basetypes/string_type.go +++ b/types/basetypes/string_type.go @@ -10,7 +10,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-go/tftypes" - tfrefinements "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" + tfrefinement "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" ) // StringTypable extends attr.Type for string types. @@ -62,15 +62,16 @@ 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() { + unknownVal := NewStringUnknown() refinements := in.Refinements() + if len(refinements) == 0 { - return NewStringUnknown(), nil + return unknownVal, nil } - unknownVal := NewStringUnknown() for _, refn := range refinements { switch refnVal := refn.(type) { - case tfrefinements.Nullness: + case tfrefinement.Nullness: if !refnVal.Nullness() { unknownVal = unknownVal.RefineAsNotNull() } else { @@ -80,7 +81,7 @@ func (t StringType) ValueFromTerraform(ctx context.Context, in tftypes.Value) (a // it into a known null value here. return NewStringNull(), nil } - case tfrefinements.StringPrefix: + case tfrefinement.StringPrefix: unknownVal = unknownVal.RefineWithPrefix(refnVal.PrefixValue()) } } diff --git a/types/basetypes/string_value.go b/types/basetypes/string_value.go index cf2b8988..897af903 100644 --- a/types/basetypes/string_value.go +++ b/types/basetypes/string_value.go @@ -12,7 +12,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/types/refinement" - tfrefinements "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" + tfrefinement "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" ) var ( @@ -98,7 +98,8 @@ type StringValue struct { // value contains the known value, if not null or unknown. value string - // TODO: doc + // refinements represents the unknown value refinement data associated with this Value. + // This field is only populated for unknown values. refinements refinement.Refinements } @@ -123,13 +124,13 @@ func (s StringValue) ToTerraformValue(_ context.Context) (tftypes.Value, error) return tftypes.NewValue(tftypes.String, tftypes.UnknownValue), nil } - unknownValRefinements := make(tfrefinements.Refinements, 0) + unknownValRefinements := make(tfrefinement.Refinements, 0) for _, refn := range s.refinements { switch refnVal := refn.(type) { case refinement.NotNull: - unknownValRefinements[tfrefinements.KeyNullness] = tfrefinements.NewNullness(false) + unknownValRefinements[tfrefinement.KeyNullness] = tfrefinement.NewNullness(false) case refinement.StringPrefix: - unknownValRefinements[tfrefinements.KeyStringPrefix] = tfrefinements.NewStringPrefix(refnVal.PrefixValue()) + unknownValRefinements[tfrefinement.KeyStringPrefix] = tfrefinement.NewStringPrefix(refnVal.PrefixValue()) } } unknownVal := tftypes.NewValue(tftypes.String, tftypes.UnknownValue) @@ -152,12 +153,18 @@ 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 } - // TODO: compare refinements? I might not be able to... to allow future refinements? - return s.value == o.value } @@ -178,9 +185,11 @@ func (s StringValue) IsUnknown() bool { // and is intended for logging and error reporting. func (s StringValue) String() string { if s.IsUnknown() { - // TODO: Also print out unknown value refinements? + if len(s.refinements) == 0 { + return attr.UnknownValueString + } - return attr.UnknownValueString + return fmt.Sprintf("", s.refinements.String()) } if s.IsNull() { @@ -211,25 +220,24 @@ func (s StringValue) ToStringValue(context.Context) (StringValue, diag.Diagnosti return s, nil } -// RefineAsNotNull will return an unknown StringValue that includes a value refinement that: +// 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 StringValue is not unknown, then no refinement will be added and the provided StringValue will be returned. +// If the provided StringValue is null or known, then the StringValue will be returned unchanged. func (s StringValue) RefineAsNotNull() StringValue { - // TODO: Should we return an error? if !s.IsUnknown() { return s } - // TODO: Do I need to do a full copy of this map? Do we need to copy any of this at all? Since it's operating on the value struct? - refns := make(refinement.Refinements, len(s.refinements)) + newRefinements := make(refinement.Refinements, len(s.refinements)) for i, refn := range s.refinements { - refns[i] = refn + newRefinements[i] = refn } - refns[refinement.KeyNotNull] = refinement.NewNotNull() + + newRefinements[refinement.KeyNotNull] = refinement.NewNotNull() newUnknownVal := NewStringUnknown() - newUnknownVal.refinements = refns + newUnknownVal.refinements = newRefinements return newUnknownVal } @@ -238,23 +246,28 @@ func (s StringValue) RefineAsNotNull() StringValue { // - 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. // -// If the StringValue is not unknown, then the provided StringValue will be returned without changes. +// 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 { - // TODO: Should we return an error? if !s.IsUnknown() { return s } - // TODO: Do I need to do a full copy of this map? Do we need to copy any of this at all? Since it's operating on the value struct? - refns := make(refinement.Refinements, len(s.refinements)) + newRefinements := make(refinement.Refinements, len(s.refinements)) for i, refn := range s.refinements { - refns[i] = refn + 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) } - refns[refinement.KeyNotNull] = refinement.NewNotNull() - refns[refinement.KeyStringPrefix] = refinement.NewStringPrefix(prefix) newUnknownVal := NewStringUnknown() - newUnknownVal.refinements = refns + newUnknownVal.refinements = newRefinements return newUnknownVal } From 4d923dc0c80d69152dd788c95891479f05573837 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Tue, 26 Nov 2024 17:54:47 -0500 Subject: [PATCH 12/39] string tests --- types/basetypes/string_type_test.go | 34 ++++++ types/basetypes/string_value_test.go | 158 +++++++++++++++++++++++++++ 2 files changed, 192 insertions(+) diff --git a/types/basetypes/string_type_test.go b/types/basetypes/string_type_test.go index 22634ecf..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(), @@ -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_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) + } + }) + } +} From e242cacfa731b70eed35a8e289c924228650f9a3 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Tue, 26 Nov 2024 18:34:34 -0500 Subject: [PATCH 13/39] clean up int64 value and type, add tests --- types/basetypes/int64_type.go | 52 +++--- types/basetypes/int64_type_test.go | 50 ++++++ types/basetypes/int64_value.go | 70 ++++---- types/basetypes/int64_value_test.go | 270 ++++++++++++++++++++++++++++ 4 files changed, 389 insertions(+), 53 deletions(-) diff --git a/types/basetypes/int64_type.go b/types/basetypes/int64_type.go index a4a8ebd7..15db4dfd 100644 --- a/types/basetypes/int64_type.go +++ b/types/basetypes/int64_type.go @@ -125,12 +125,13 @@ 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() { + unknownVal := NewInt64Unknown() refinements := in.Refinements() + if len(refinements) == 0 { - return NewInt64Unknown(), nil + return unknownVal, nil } - unknownVal := NewInt64Unknown() for _, refn := range refinements { switch refnVal := refn.(type) { case tfrefinements.Nullness: @@ -144,20 +145,18 @@ func (t Int64Type) ValueFromTerraform(ctx context.Context, in tftypes.Value) (at return NewInt64Null(), nil } case tfrefinements.NumberLowerBound: - // TODO: I don't think this is safe, but not sure what the expectation should be? - // Should I just chop the decimal off? - // Could also just directly create the refinement here, rather than using the int64 facing one? - // TODO: use-case, resource A sets an unknown value refinement with float, resource B receives this refinement - // and chops the decimal point off, thus changing the refinement, which is invalid. - boundVal, _ := refnVal.LowerBound().Int64() + // 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 tfrefinements.NumberUpperBound: - // TODO: I don't think this is safe, but not sure what the expectation should be? - // Should I just chop the decimal off? - // Could also just directly create the refinement here, rather than using the int64 facing one? - // TODO: use-case, resource A sets an unknown value refinement with float, resource B receives this refinement - // and chops the decimal point off, thus changing the refinement, which is invalid. - boundVal, _ := refnVal.UpperBound().Int64() + // 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()) } } @@ -176,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 @@ -194,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 136bfb56..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(), @@ -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 ef8263db..9a718c3e 100644 --- a/types/basetypes/int64_value.go +++ b/types/basetypes/int64_value.go @@ -89,7 +89,8 @@ type Int64Value struct { // value contains the known value, if not null or unknown. value int64 - // TODO: doc + // refinements represents the unknown value refinement data associated with this Value. + // This field is only populated for unknown values. refinements refinement.Refinements } @@ -105,12 +106,18 @@ 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 } - // TODO: compare refinements? I might not be able to... to allow future refinements? - return i.value == o.value } @@ -136,10 +143,11 @@ func (i Int64Value) ToTerraformValue(ctx context.Context) (tftypes.Value, error) case refinement.NotNull: unknownValRefinements[tfrefinements.KeyNullness] = tfrefinements.NewNullness(false) case refinement.Int64LowerBound: - // TODO: is int64 to big.NewFloat safe? I think it is... - unknownValRefinements[tfrefinements.KeyNumberLowerBound] = tfrefinements.NewNumberLowerBound(big.NewFloat(float64(refnVal.LowerBound())), refnVal.IsInclusive()) + lowerBound := new(big.Float).SetInt64(refnVal.LowerBound()) + unknownValRefinements[tfrefinements.KeyNumberLowerBound] = tfrefinements.NewNumberLowerBound(lowerBound, refnVal.IsInclusive()) case refinement.Int64UpperBound: - unknownValRefinements[tfrefinements.KeyNumberUpperBound] = tfrefinements.NewNumberUpperBound(big.NewFloat(float64(refnVal.UpperBound())), refnVal.IsInclusive()) + upperBound := new(big.Float).SetInt64(refnVal.UpperBound()) + unknownValRefinements[tfrefinements.KeyNumberUpperBound] = tfrefinements.NewNumberUpperBound(upperBound, refnVal.IsInclusive()) } } unknownVal := tftypes.NewValue(tftypes.Number, tftypes.UnknownValue) @@ -170,8 +178,11 @@ func (i Int64Value) IsUnknown() bool { // and is intended for logging and error reporting. func (i Int64Value) String() string { if i.IsUnknown() { - // TODO: Also print out unknown value refinements? - return attr.UnknownValueString + if len(i.refinements) == 0 { + return attr.UnknownValueString + } + + return fmt.Sprintf("", i.refinements.String()) } if i.IsNull() { @@ -205,22 +216,21 @@ func (i Int64Value) ToInt64Value(context.Context) (Int64Value, diag.Diagnostics) // 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 Int64Value is not unknown, then no refinement will be added and the provided Int64Value will be returned. +// If the provided Int64Value is null or known, then the Int64Value will be returned unchanged. func (i Int64Value) RefineAsNotNull() Int64Value { - // TODO: Should we return an error? if !i.IsUnknown() { return i } - // TODO: Do I need to do a full copy of this map? Do we need to copy any of this at all? Since it's operating on the value struct? - refns := make(refinement.Refinements, len(i.refinements)) + newRefinements := make(refinement.Refinements, len(i.refinements)) for i, refn := range i.refinements { - refns[i] = refn + newRefinements[i] = refn } - refns[refinement.KeyNotNull] = refinement.NewNotNull() + + newRefinements[refinement.KeyNotNull] = refinement.NewNotNull() newUnknownVal := NewInt64Unknown() - newUnknownVal.refinements = refns + newUnknownVal.refinements = newRefinements return newUnknownVal } @@ -229,23 +239,22 @@ func (i Int64Value) RefineAsNotNull() Int64Value { // - 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 Int64Value is not unknown, then no refinement will be added and the provided Int64Value will be returned. +// If the provided Int64Value is null or known, then the Int64Value will be returned unchanged. func (i Int64Value) RefineWithLowerBound(lowerBound int64, inclusive bool) Int64Value { - // TODO: Should we return an error? if !i.IsUnknown() { return i } - // TODO: Do I need to do a full copy of this map? Do we need to copy any of this at all? Since it's operating on the value struct? - refns := make(refinement.Refinements, len(i.refinements)) + newRefinements := make(refinement.Refinements, len(i.refinements)) for i, refn := range i.refinements { - refns[i] = refn + newRefinements[i] = refn } - refns[refinement.KeyNotNull] = refinement.NewNotNull() - refns[refinement.KeyNumberLowerBound] = refinement.NewInt64LowerBound(lowerBound, inclusive) + + newRefinements[refinement.KeyNotNull] = refinement.NewNotNull() + newRefinements[refinement.KeyNumberLowerBound] = refinement.NewInt64LowerBound(lowerBound, inclusive) newUnknownVal := NewInt64Unknown() - newUnknownVal.refinements = refns + newUnknownVal.refinements = newRefinements return newUnknownVal } @@ -254,23 +263,22 @@ func (i Int64Value) RefineWithLowerBound(lowerBound int64, inclusive bool) Int64 // - 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 Int64Value is not unknown, then no refinement will be added and the provided Int64Value will be returned. +// If the provided Int64Value is null or known, then the Int64Value will be returned unchanged. func (i Int64Value) RefineWithUpperBound(upperBound int64, inclusive bool) Int64Value { - // TODO: Should we return an error? if !i.IsUnknown() { return i } - // TODO: Do I need to do a full copy of this map? Do we need to copy any of this at all? Since it's operating on the value struct? - refns := make(refinement.Refinements, len(i.refinements)) + newRefinements := make(refinement.Refinements, len(i.refinements)) for i, refn := range i.refinements { - refns[i] = refn + newRefinements[i] = refn } - refns[refinement.KeyNotNull] = refinement.NewNotNull() - refns[refinement.KeyNumberUpperBound] = refinement.NewInt64UpperBound(upperBound, inclusive) + + newRefinements[refinement.KeyNotNull] = refinement.NewNotNull() + newRefinements[refinement.KeyNumberUpperBound] = refinement.NewInt64UpperBound(upperBound, inclusive) newUnknownVal := NewInt64Unknown() - newUnknownVal.refinements = refns + newUnknownVal.refinements = newRefinements return newUnknownVal } 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) + } + }) + } +} From 12d8bb815f00cf27369103e670b8289f195d3177 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Wed, 27 Nov 2024 07:04:25 -0500 Subject: [PATCH 14/39] int32 refinements --- types/basetypes/int32_type.go | 68 +++++-- types/basetypes/int32_type_test.go | 50 ++++++ types/basetypes/int32_value.go | 188 ++++++++++++++++++- types/basetypes/int32_value_test.go | 270 ++++++++++++++++++++++++++++ 4 files changed, 562 insertions(+), 14 deletions(-) diff --git a/types/basetypes/int32_type.go b/types/basetypes/int32_type.go index 3943e88a..d7988d41 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" + tfrefinements "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 tfrefinements.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 tfrefinements.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 tfrefinements.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 04f02f4f..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(), @@ -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..c4adc133 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" + tfrefinements "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(tfrefinements.Refinements, 0) + for _, refn := range i.refinements { + switch refnVal := refn.(type) { + case refinement.NotNull: + unknownValRefinements[tfrefinements.KeyNullness] = tfrefinements.NewNullness(false) + case refinement.Int32LowerBound: + lowerBound := new(big.Float).SetInt64(int64(refnVal.LowerBound())) + unknownValRefinements[tfrefinements.KeyNumberLowerBound] = tfrefinements.NewNumberLowerBound(lowerBound, refnVal.IsInclusive()) + case refinement.Int32UpperBound: + upperBound := new(big.Float).SetInt64(int64(refnVal.UpperBound())) + unknownValRefinements[tfrefinements.KeyNumberUpperBound] = tfrefinements.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) + } + }) + } +} From 9f965c4d0614b0d167b0ab51d3e868b2d98cfaa4 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Wed, 27 Nov 2024 07:44:23 -0500 Subject: [PATCH 15/39] float64 refinements --- types/basetypes/float64_type.go | 70 +++++-- types/basetypes/float64_type_test.go | 49 +++++ types/basetypes/float64_value.go | 185 +++++++++++++++++- types/basetypes/float64_value_test.go | 270 ++++++++++++++++++++++++++ 4 files changed, 559 insertions(+), 15 deletions(-) diff --git a/types/basetypes/float64_type.go b/types/basetypes/float64_type.go index a783201e..e9da756d 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" + tfrefinements "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 tfrefinements.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 tfrefinements.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 tfrefinements.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 54ae58f6..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(), @@ -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..811332b8 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" + tfrefinements "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(tfrefinements.Refinements, 0) + for _, refn := range f.refinements { + switch refnVal := refn.(type) { + case refinement.NotNull: + unknownValRefinements[tfrefinements.KeyNullness] = tfrefinements.NewNullness(false) + case refinement.Float64LowerBound: + lowerBound := big.NewFloat(refnVal.LowerBound()) + unknownValRefinements[tfrefinements.KeyNumberLowerBound] = tfrefinements.NewNumberLowerBound(lowerBound, refnVal.IsInclusive()) + case refinement.Float64UpperBound: + upperBound := big.NewFloat(refnVal.UpperBound()) + unknownValRefinements[tfrefinements.KeyNumberUpperBound] = tfrefinements.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 (i Float64Value) RefineAsNotNull() Float64Value { + 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 := 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 (i Float64Value) RefineWithLowerBound(lowerBound float64, inclusive bool) Float64Value { + 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.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 (i Float64Value) RefineWithUpperBound(upperBound float64, inclusive bool) Float64Value { + 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.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 (i Float64Value) 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 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 (i Float64Value) LowerBoundRefinement() (*refinement.Float64LowerBound, bool) { + if !i.IsUnknown() { + return nil, false + } + + refn, ok := i.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 (i Float64Value) UpperBoundRefinement() (*refinement.Float64UpperBound, bool) { + if !i.IsUnknown() { + return nil, false + } + + refn, ok := i.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) + } + }) + } +} From c65976cd7e3b688e366e7df903ba6991eacabcae Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Wed, 27 Nov 2024 08:07:04 -0500 Subject: [PATCH 16/39] float 32 refinements --- types/basetypes/float32_type.go | 72 +++++-- types/basetypes/float32_type_test.go | 49 +++++ types/basetypes/float32_value.go | 185 +++++++++++++++++- types/basetypes/float32_value_test.go | 270 ++++++++++++++++++++++++++ 4 files changed, 560 insertions(+), 16 deletions(-) diff --git a/types/basetypes/float32_type.go b/types/basetypes/float32_type.go index 77d35286..9b087f8e 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" + tfrefinements "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 tfrefinements.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 tfrefinements.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 tfrefinements.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 54b6061d..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(), @@ -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..3672c626 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" + tfrefinements "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(tfrefinements.Refinements, 0) + for _, refn := range f.refinements { + switch refnVal := refn.(type) { + case refinement.NotNull: + unknownValRefinements[tfrefinements.KeyNullness] = tfrefinements.NewNullness(false) + case refinement.Float32LowerBound: + lowerBound := big.NewFloat(float64(refnVal.LowerBound())) + unknownValRefinements[tfrefinements.KeyNumberLowerBound] = tfrefinements.NewNumberLowerBound(lowerBound, refnVal.IsInclusive()) + case refinement.Float32UpperBound: + upperBound := big.NewFloat(float64(refnVal.UpperBound())) + unknownValRefinements[tfrefinements.KeyNumberUpperBound] = tfrefinements.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 (i Float32Value) RefineAsNotNull() Float32Value { + 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 := 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 (i Float32Value) RefineWithLowerBound(lowerBound float32, inclusive bool) Float32Value { + 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.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 (i Float32Value) RefineWithUpperBound(upperBound float32, inclusive bool) Float32Value { + 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.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 (i Float32Value) 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 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 (i Float32Value) LowerBoundRefinement() (*refinement.Float32LowerBound, bool) { + if !i.IsUnknown() { + return nil, false + } + + refn, ok := i.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 (i Float32Value) UpperBoundRefinement() (*refinement.Float32UpperBound, bool) { + if !i.IsUnknown() { + return nil, false + } + + refn, ok := i.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) + } + }) + } +} From ab222bc5cb2d82112bfa05f928ea97a5bba35b72 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Wed, 27 Nov 2024 08:32:47 -0500 Subject: [PATCH 17/39] variable change --- types/basetypes/float32_value.go | 48 ++++++++++++++++---------------- types/basetypes/float64_value.go | 48 ++++++++++++++++---------------- 2 files changed, 48 insertions(+), 48 deletions(-) diff --git a/types/basetypes/float32_value.go b/types/basetypes/float32_value.go index 3672c626..42b4fd1b 100644 --- a/types/basetypes/float32_value.go +++ b/types/basetypes/float32_value.go @@ -259,13 +259,13 @@ func (f Float32Value) ToFloat32Value(context.Context) (Float32Value, diag.Diagno // - 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 (i Float32Value) RefineAsNotNull() Float32Value { - if !i.IsUnknown() { - return i +func (f Float32Value) RefineAsNotNull() Float32Value { + if !f.IsUnknown() { + return f } - newRefinements := make(refinement.Refinements, len(i.refinements)) - for i, refn := range i.refinements { + newRefinements := make(refinement.Refinements, len(f.refinements)) + for i, refn := range f.refinements { newRefinements[i] = refn } @@ -282,13 +282,13 @@ func (i Float32Value) RefineAsNotNull() Float32Value { // - 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 (i Float32Value) RefineWithLowerBound(lowerBound float32, inclusive bool) Float32Value { - if !i.IsUnknown() { - return i +func (f Float32Value) RefineWithLowerBound(lowerBound float32, inclusive bool) Float32Value { + if !f.IsUnknown() { + return f } - newRefinements := make(refinement.Refinements, len(i.refinements)) - for i, refn := range i.refinements { + newRefinements := make(refinement.Refinements, len(f.refinements)) + for i, refn := range f.refinements { newRefinements[i] = refn } @@ -306,13 +306,13 @@ func (i Float32Value) RefineWithLowerBound(lowerBound float32, inclusive bool) F // - 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 (i Float32Value) RefineWithUpperBound(upperBound float32, inclusive bool) Float32Value { - if !i.IsUnknown() { - return i +func (f Float32Value) RefineWithUpperBound(upperBound float32, inclusive bool) Float32Value { + if !f.IsUnknown() { + return f } - newRefinements := make(refinement.Refinements, len(i.refinements)) - for i, refn := range i.refinements { + newRefinements := make(refinement.Refinements, len(f.refinements)) + for i, refn := range f.refinements { newRefinements[i] = refn } @@ -330,12 +330,12 @@ func (i Float32Value) RefineWithUpperBound(upperBound float32, inclusive bool) F // 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 (i Float32Value) NotNullRefinement() (*refinement.NotNull, bool) { - if !i.IsUnknown() { +func (f Float32Value) NotNullRefinement() (*refinement.NotNull, bool) { + if !f.IsUnknown() { return nil, false } - refn, ok := i.refinements[refinement.KeyNotNull] + refn, ok := f.refinements[refinement.KeyNotNull] if !ok { return nil, false } @@ -355,12 +355,12 @@ func (i Float32Value) NotNullRefinement() (*refinement.NotNull, bool) { // refinement data. // // An Float32LowerBound value refinement can be added to an unknown value via the `RefineWithLowerBound` method. -func (i Float32Value) LowerBoundRefinement() (*refinement.Float32LowerBound, bool) { - if !i.IsUnknown() { +func (f Float32Value) LowerBoundRefinement() (*refinement.Float32LowerBound, bool) { + if !f.IsUnknown() { return nil, false } - refn, ok := i.refinements[refinement.KeyNumberLowerBound] + refn, ok := f.refinements[refinement.KeyNumberLowerBound] if !ok { return nil, false } @@ -380,12 +380,12 @@ func (i Float32Value) LowerBoundRefinement() (*refinement.Float32LowerBound, boo // refinement data. // // A Float32UpperBound value refinement can be added to an unknown value via the `RefineWithUpperBound` method. -func (i Float32Value) UpperBoundRefinement() (*refinement.Float32UpperBound, bool) { - if !i.IsUnknown() { +func (f Float32Value) UpperBoundRefinement() (*refinement.Float32UpperBound, bool) { + if !f.IsUnknown() { return nil, false } - refn, ok := i.refinements[refinement.KeyNumberUpperBound] + refn, ok := f.refinements[refinement.KeyNumberUpperBound] if !ok { return nil, false } diff --git a/types/basetypes/float64_value.go b/types/basetypes/float64_value.go index 811332b8..741d03ec 100644 --- a/types/basetypes/float64_value.go +++ b/types/basetypes/float64_value.go @@ -264,13 +264,13 @@ func (f Float64Value) ToFloat64Value(context.Context) (Float64Value, diag.Diagno // - 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 (i Float64Value) RefineAsNotNull() Float64Value { - if !i.IsUnknown() { - return i +func (f Float64Value) RefineAsNotNull() Float64Value { + if !f.IsUnknown() { + return f } - newRefinements := make(refinement.Refinements, len(i.refinements)) - for i, refn := range i.refinements { + newRefinements := make(refinement.Refinements, len(f.refinements)) + for i, refn := range f.refinements { newRefinements[i] = refn } @@ -287,13 +287,13 @@ func (i Float64Value) RefineAsNotNull() Float64Value { // - 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 (i Float64Value) RefineWithLowerBound(lowerBound float64, inclusive bool) Float64Value { - if !i.IsUnknown() { - return i +func (f Float64Value) RefineWithLowerBound(lowerBound float64, inclusive bool) Float64Value { + if !f.IsUnknown() { + return f } - newRefinements := make(refinement.Refinements, len(i.refinements)) - for i, refn := range i.refinements { + newRefinements := make(refinement.Refinements, len(f.refinements)) + for i, refn := range f.refinements { newRefinements[i] = refn } @@ -311,13 +311,13 @@ func (i Float64Value) RefineWithLowerBound(lowerBound float64, inclusive bool) F // - 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 (i Float64Value) RefineWithUpperBound(upperBound float64, inclusive bool) Float64Value { - if !i.IsUnknown() { - return i +func (f Float64Value) RefineWithUpperBound(upperBound float64, inclusive bool) Float64Value { + if !f.IsUnknown() { + return f } - newRefinements := make(refinement.Refinements, len(i.refinements)) - for i, refn := range i.refinements { + newRefinements := make(refinement.Refinements, len(f.refinements)) + for i, refn := range f.refinements { newRefinements[i] = refn } @@ -335,12 +335,12 @@ func (i Float64Value) RefineWithUpperBound(upperBound float64, inclusive bool) F // 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 (i Float64Value) NotNullRefinement() (*refinement.NotNull, bool) { - if !i.IsUnknown() { +func (f Float64Value) NotNullRefinement() (*refinement.NotNull, bool) { + if !f.IsUnknown() { return nil, false } - refn, ok := i.refinements[refinement.KeyNotNull] + refn, ok := f.refinements[refinement.KeyNotNull] if !ok { return nil, false } @@ -360,12 +360,12 @@ func (i Float64Value) NotNullRefinement() (*refinement.NotNull, bool) { // refinement data. // // An Float64LowerBound value refinement can be added to an unknown value via the `RefineWithLowerBound` method. -func (i Float64Value) LowerBoundRefinement() (*refinement.Float64LowerBound, bool) { - if !i.IsUnknown() { +func (f Float64Value) LowerBoundRefinement() (*refinement.Float64LowerBound, bool) { + if !f.IsUnknown() { return nil, false } - refn, ok := i.refinements[refinement.KeyNumberLowerBound] + refn, ok := f.refinements[refinement.KeyNumberLowerBound] if !ok { return nil, false } @@ -385,12 +385,12 @@ func (i Float64Value) LowerBoundRefinement() (*refinement.Float64LowerBound, boo // refinement data. // // A Float64UpperBound value refinement can be added to an unknown value via the `RefineWithUpperBound` method. -func (i Float64Value) UpperBoundRefinement() (*refinement.Float64UpperBound, bool) { - if !i.IsUnknown() { +func (f Float64Value) UpperBoundRefinement() (*refinement.Float64UpperBound, bool) { + if !f.IsUnknown() { return nil, false } - refn, ok := i.refinements[refinement.KeyNumberUpperBound] + refn, ok := f.refinements[refinement.KeyNumberUpperBound] if !ok { return nil, false } From 82ad8e7718c9b2906fae5a0758b572302e89ac60 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Wed, 27 Nov 2024 08:37:16 -0500 Subject: [PATCH 18/39] number refinements --- types/basetypes/number_type.go | 29 ++- types/basetypes/number_type_test.go | 49 +++++ types/basetypes/number_value.go | 185 +++++++++++++++++- types/basetypes/number_value_test.go | 270 +++++++++++++++++++++++++++ 4 files changed, 529 insertions(+), 4 deletions(-) diff --git a/types/basetypes/number_type.go b/types/basetypes/number_type.go index 3cd2a92f..b2c27a3a 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" + tfrefinements "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 tfrefinements.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 tfrefinements.NumberLowerBound: + unknownVal = unknownVal.RefineWithLowerBound(refnVal.LowerBound(), refnVal.IsInclusive()) + case tfrefinements.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 ff862247..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(), @@ -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..a3799992 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" + tfrefinements "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(tfrefinements.Refinements, 0) + for _, refn := range n.refinements { + switch refnVal := refn.(type) { + case refinement.NotNull: + unknownValRefinements[tfrefinements.KeyNullness] = tfrefinements.NewNullness(false) + case refinement.NumberLowerBound: + unknownValRefinements[tfrefinements.KeyNumberLowerBound] = tfrefinements.NewNumberLowerBound(refnVal.LowerBound(), refnVal.IsInclusive()) + case refinement.NumberUpperBound: + unknownValRefinements[tfrefinements.KeyNumberUpperBound] = tfrefinements.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) + } + }) + } +} From cfa53db47efaa1d3530e3a11be9324fa7f6da991 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Wed, 27 Nov 2024 08:48:43 -0500 Subject: [PATCH 19/39] bool refinements --- types/basetypes/bool_type.go | 25 ++++++++- types/basetypes/bool_type_test.go | 27 ++++++++++ types/basetypes/bool_value.go | 83 ++++++++++++++++++++++++++++-- types/basetypes/bool_value_test.go | 75 +++++++++++++++++++++++++++ 4 files changed, 206 insertions(+), 4 deletions(-) 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 535be353..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(), @@ -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) + } + }) + } +} From cdaf3d962e8a6a0fb8d0fe91549df1cd95991aad Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Wed, 27 Nov 2024 09:18:33 -0500 Subject: [PATCH 20/39] object refinements --- types/basetypes/object_type.go | 28 +++++- types/basetypes/object_type_test.go | 42 +++++++++ types/basetypes/object_value.go | 85 +++++++++++++++++- types/basetypes/object_value_test.go | 130 +++++++++++++++++++++++++++ 4 files changed, 280 insertions(+), 5 deletions(-) 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) + } + }) + } +} From cd8d8ed11d93002e9b809307a83aea3454031714 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Wed, 27 Nov 2024 10:13:51 -0500 Subject: [PATCH 21/39] tuple refinements --- types/basetypes/tuple_type.go | 25 ++++++++- types/basetypes/tuple_type_test.go | 36 ++++++++++++ types/basetypes/tuple_value.go | 85 ++++++++++++++++++++++++++++- types/basetypes/tuple_value_test.go | 80 +++++++++++++++++++++++++++ 4 files changed, 222 insertions(+), 4 deletions(-) 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) + } + }) + } +} From 54b5cfbf00ecdfbb98b4af04a236d2a756aae7d1 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Wed, 27 Nov 2024 17:05:49 -0500 Subject: [PATCH 22/39] list refinements --- types/basetypes/float32_type.go | 8 +- types/basetypes/float32_value.go | 10 +- types/basetypes/float64_type.go | 8 +- types/basetypes/float64_value.go | 10 +- types/basetypes/int32_type.go | 8 +- types/basetypes/int32_value.go | 10 +- types/basetypes/int64_type.go | 8 +- types/basetypes/int64_value.go | 10 +- types/basetypes/list_type.go | 29 +++- types/basetypes/list_type_test.go | 61 +++++++ types/basetypes/list_value.go | 185 ++++++++++++++++++++- types/basetypes/list_value_test.go | 255 +++++++++++++++++++++++++++++ types/basetypes/number_type.go | 8 +- types/basetypes/number_value.go | 10 +- 14 files changed, 571 insertions(+), 49 deletions(-) diff --git a/types/basetypes/float32_type.go b/types/basetypes/float32_type.go index 9b087f8e..a1a427ae 100644 --- a/types/basetypes/float32_type.go +++ b/types/basetypes/float32_type.go @@ -10,7 +10,7 @@ import ( "math/big" "github.com/hashicorp/terraform-plugin-go/tftypes" - tfrefinements "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" + tfrefinement "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" @@ -75,7 +75,7 @@ func (t Float32Type) ValueFromTerraform(ctx context.Context, in tftypes.Value) ( for _, refn := range refinements { switch refnVal := refn.(type) { - case tfrefinements.Nullness: + case tfrefinement.Nullness: if !refnVal.Nullness() { unknownVal = unknownVal.RefineAsNotNull() } else { @@ -85,13 +85,13 @@ func (t Float32Type) ValueFromTerraform(ctx context.Context, in tftypes.Value) ( // it into a known null value here. return NewFloat32Null(), nil } - case tfrefinements.NumberLowerBound: + 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 tfrefinements.NumberUpperBound: + case tfrefinement.NumberUpperBound: boundVal, err := tryBigFloatAsFloat32(ctx, refnVal.UpperBound()) if err != nil { return nil, fmt.Errorf("error parsing upper bound refinement: %w", err) diff --git a/types/basetypes/float32_value.go b/types/basetypes/float32_value.go index 42b4fd1b..223b46d2 100644 --- a/types/basetypes/float32_value.go +++ b/types/basetypes/float32_value.go @@ -13,7 +13,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/types/refinement" - tfrefinements "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" + tfrefinement "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" ) var ( @@ -166,17 +166,17 @@ func (f Float32Value) ToTerraformValue(ctx context.Context) (tftypes.Value, erro return tftypes.NewValue(tftypes.Number, tftypes.UnknownValue), nil } - unknownValRefinements := make(tfrefinements.Refinements, 0) + unknownValRefinements := make(tfrefinement.Refinements, 0) for _, refn := range f.refinements { switch refnVal := refn.(type) { case refinement.NotNull: - unknownValRefinements[tfrefinements.KeyNullness] = tfrefinements.NewNullness(false) + unknownValRefinements[tfrefinement.KeyNullness] = tfrefinement.NewNullness(false) case refinement.Float32LowerBound: lowerBound := big.NewFloat(float64(refnVal.LowerBound())) - unknownValRefinements[tfrefinements.KeyNumberLowerBound] = tfrefinements.NewNumberLowerBound(lowerBound, refnVal.IsInclusive()) + unknownValRefinements[tfrefinement.KeyNumberLowerBound] = tfrefinement.NewNumberLowerBound(lowerBound, refnVal.IsInclusive()) case refinement.Float32UpperBound: upperBound := big.NewFloat(float64(refnVal.UpperBound())) - unknownValRefinements[tfrefinements.KeyNumberUpperBound] = tfrefinements.NewNumberUpperBound(upperBound, refnVal.IsInclusive()) + unknownValRefinements[tfrefinement.KeyNumberUpperBound] = tfrefinement.NewNumberUpperBound(upperBound, refnVal.IsInclusive()) } } unknownVal := tftypes.NewValue(tftypes.Number, tftypes.UnknownValue) diff --git a/types/basetypes/float64_type.go b/types/basetypes/float64_type.go index e9da756d..fc13fd60 100644 --- a/types/basetypes/float64_type.go +++ b/types/basetypes/float64_type.go @@ -10,7 +10,7 @@ import ( "math/big" "github.com/hashicorp/terraform-plugin-go/tftypes" - tfrefinements "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" + tfrefinement "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/attr/xattr" @@ -139,7 +139,7 @@ func (t Float64Type) ValueFromTerraform(ctx context.Context, in tftypes.Value) ( for _, refn := range refinements { switch refnVal := refn.(type) { - case tfrefinements.Nullness: + case tfrefinement.Nullness: if !refnVal.Nullness() { unknownVal = unknownVal.RefineAsNotNull() } else { @@ -149,13 +149,13 @@ func (t Float64Type) ValueFromTerraform(ctx context.Context, in tftypes.Value) ( // it into a known null value here. return NewFloat64Null(), nil } - case tfrefinements.NumberLowerBound: + 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 tfrefinements.NumberUpperBound: + case tfrefinement.NumberUpperBound: boundVal, err := tryBigFloatAsFloat64(refnVal.UpperBound()) if err != nil { return nil, fmt.Errorf("error parsing upper bound refinement: %w", err) diff --git a/types/basetypes/float64_value.go b/types/basetypes/float64_value.go index 741d03ec..b1800b9e 100644 --- a/types/basetypes/float64_value.go +++ b/types/basetypes/float64_value.go @@ -13,7 +13,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/types/refinement" - tfrefinements "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" + tfrefinement "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" ) var ( @@ -172,17 +172,17 @@ func (f Float64Value) ToTerraformValue(ctx context.Context) (tftypes.Value, erro return tftypes.NewValue(tftypes.Number, tftypes.UnknownValue), nil } - unknownValRefinements := make(tfrefinements.Refinements, 0) + unknownValRefinements := make(tfrefinement.Refinements, 0) for _, refn := range f.refinements { switch refnVal := refn.(type) { case refinement.NotNull: - unknownValRefinements[tfrefinements.KeyNullness] = tfrefinements.NewNullness(false) + unknownValRefinements[tfrefinement.KeyNullness] = tfrefinement.NewNullness(false) case refinement.Float64LowerBound: lowerBound := big.NewFloat(refnVal.LowerBound()) - unknownValRefinements[tfrefinements.KeyNumberLowerBound] = tfrefinements.NewNumberLowerBound(lowerBound, refnVal.IsInclusive()) + unknownValRefinements[tfrefinement.KeyNumberLowerBound] = tfrefinement.NewNumberLowerBound(lowerBound, refnVal.IsInclusive()) case refinement.Float64UpperBound: upperBound := big.NewFloat(refnVal.UpperBound()) - unknownValRefinements[tfrefinements.KeyNumberUpperBound] = tfrefinements.NewNumberUpperBound(upperBound, refnVal.IsInclusive()) + unknownValRefinements[tfrefinement.KeyNumberUpperBound] = tfrefinement.NewNumberUpperBound(upperBound, refnVal.IsInclusive()) } } unknownVal := tftypes.NewValue(tftypes.Number, tftypes.UnknownValue) diff --git a/types/basetypes/int32_type.go b/types/basetypes/int32_type.go index d7988d41..c30202df 100644 --- a/types/basetypes/int32_type.go +++ b/types/basetypes/int32_type.go @@ -10,7 +10,7 @@ import ( "math/big" "github.com/hashicorp/terraform-plugin-go/tftypes" - tfrefinements "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" + tfrefinement "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" @@ -74,7 +74,7 @@ func (t Int32Type) ValueFromTerraform(ctx context.Context, in tftypes.Value) (at for _, refn := range refinements { switch refnVal := refn.(type) { - case tfrefinements.Nullness: + case tfrefinement.Nullness: if !refnVal.Nullness() { unknownVal = unknownVal.RefineAsNotNull() } else { @@ -84,14 +84,14 @@ func (t Int32Type) ValueFromTerraform(ctx context.Context, in tftypes.Value) (at // it into a known null value here. return NewInt32Null(), nil } - case tfrefinements.NumberLowerBound: + 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 tfrefinements.NumberUpperBound: + 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 { diff --git a/types/basetypes/int32_value.go b/types/basetypes/int32_value.go index c4adc133..1b8e5547 100644 --- a/types/basetypes/int32_value.go +++ b/types/basetypes/int32_value.go @@ -13,7 +13,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/types/refinement" - tfrefinements "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" + tfrefinement "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" ) var ( @@ -137,17 +137,17 @@ func (i Int32Value) ToTerraformValue(ctx context.Context) (tftypes.Value, error) return tftypes.NewValue(tftypes.Number, tftypes.UnknownValue), nil } - unknownValRefinements := make(tfrefinements.Refinements, 0) + unknownValRefinements := make(tfrefinement.Refinements, 0) for _, refn := range i.refinements { switch refnVal := refn.(type) { case refinement.NotNull: - unknownValRefinements[tfrefinements.KeyNullness] = tfrefinements.NewNullness(false) + unknownValRefinements[tfrefinement.KeyNullness] = tfrefinement.NewNullness(false) case refinement.Int32LowerBound: lowerBound := new(big.Float).SetInt64(int64(refnVal.LowerBound())) - unknownValRefinements[tfrefinements.KeyNumberLowerBound] = tfrefinements.NewNumberLowerBound(lowerBound, refnVal.IsInclusive()) + unknownValRefinements[tfrefinement.KeyNumberLowerBound] = tfrefinement.NewNumberLowerBound(lowerBound, refnVal.IsInclusive()) case refinement.Int32UpperBound: upperBound := new(big.Float).SetInt64(int64(refnVal.UpperBound())) - unknownValRefinements[tfrefinements.KeyNumberUpperBound] = tfrefinements.NewNumberUpperBound(upperBound, refnVal.IsInclusive()) + unknownValRefinements[tfrefinement.KeyNumberUpperBound] = tfrefinement.NewNumberUpperBound(upperBound, refnVal.IsInclusive()) } } unknownVal := tftypes.NewValue(tftypes.Number, tftypes.UnknownValue) diff --git a/types/basetypes/int64_type.go b/types/basetypes/int64_type.go index 15db4dfd..c6ee0af7 100644 --- a/types/basetypes/int64_type.go +++ b/types/basetypes/int64_type.go @@ -9,7 +9,7 @@ import ( "math/big" "github.com/hashicorp/terraform-plugin-go/tftypes" - tfrefinements "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" + tfrefinement "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/attr/xattr" @@ -134,7 +134,7 @@ func (t Int64Type) ValueFromTerraform(ctx context.Context, in tftypes.Value) (at for _, refn := range refinements { switch refnVal := refn.(type) { - case tfrefinements.Nullness: + case tfrefinement.Nullness: if !refnVal.Nullness() { unknownVal = unknownVal.RefineAsNotNull() } else { @@ -144,14 +144,14 @@ func (t Int64Type) ValueFromTerraform(ctx context.Context, in tftypes.Value) (at // it into a known null value here. return NewInt64Null(), nil } - case tfrefinements.NumberLowerBound: + 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 tfrefinements.NumberUpperBound: + 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 { diff --git a/types/basetypes/int64_value.go b/types/basetypes/int64_value.go index 9a718c3e..a5e7814a 100644 --- a/types/basetypes/int64_value.go +++ b/types/basetypes/int64_value.go @@ -13,7 +13,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/types/refinement" - tfrefinements "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" + tfrefinement "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" ) var ( @@ -137,17 +137,17 @@ func (i Int64Value) ToTerraformValue(ctx context.Context) (tftypes.Value, error) return tftypes.NewValue(tftypes.Number, tftypes.UnknownValue), nil } - unknownValRefinements := make(tfrefinements.Refinements, 0) + unknownValRefinements := make(tfrefinement.Refinements, 0) for _, refn := range i.refinements { switch refnVal := refn.(type) { case refinement.NotNull: - unknownValRefinements[tfrefinements.KeyNullness] = tfrefinements.NewNullness(false) + unknownValRefinements[tfrefinement.KeyNullness] = tfrefinement.NewNullness(false) case refinement.Int64LowerBound: lowerBound := new(big.Float).SetInt64(refnVal.LowerBound()) - unknownValRefinements[tfrefinements.KeyNumberLowerBound] = tfrefinements.NewNumberLowerBound(lowerBound, refnVal.IsInclusive()) + unknownValRefinements[tfrefinement.KeyNumberLowerBound] = tfrefinement.NewNumberLowerBound(lowerBound, refnVal.IsInclusive()) case refinement.Int64UpperBound: upperBound := new(big.Float).SetInt64(refnVal.UpperBound()) - unknownValRefinements[tfrefinements.KeyNumberUpperBound] = tfrefinements.NewNumberUpperBound(upperBound, refnVal.IsInclusive()) + unknownValRefinements[tfrefinement.KeyNumberUpperBound] = tfrefinement.NewNumberUpperBound(upperBound, refnVal.IsInclusive()) } } unknownVal := tftypes.NewValue(tftypes.Number, tftypes.UnknownValue) 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/number_type.go b/types/basetypes/number_type.go index b2c27a3a..3cd6fa19 100644 --- a/types/basetypes/number_type.go +++ b/types/basetypes/number_type.go @@ -11,7 +11,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-go/tftypes" - tfrefinements "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" + tfrefinement "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" ) // NumberTypable extends attr.Type for number types. @@ -72,7 +72,7 @@ func (t NumberType) ValueFromTerraform(ctx context.Context, in tftypes.Value) (a for _, refn := range refinements { switch refnVal := refn.(type) { - case tfrefinements.Nullness: + case tfrefinement.Nullness: if !refnVal.Nullness() { unknownVal = unknownVal.RefineAsNotNull() } else { @@ -82,9 +82,9 @@ func (t NumberType) ValueFromTerraform(ctx context.Context, in tftypes.Value) (a // it into a known null value here. return NewNumberNull(), nil } - case tfrefinements.NumberLowerBound: + case tfrefinement.NumberLowerBound: unknownVal = unknownVal.RefineWithLowerBound(refnVal.LowerBound(), refnVal.IsInclusive()) - case tfrefinements.NumberUpperBound: + case tfrefinement.NumberUpperBound: unknownVal = unknownVal.RefineWithUpperBound(refnVal.UpperBound(), refnVal.IsInclusive()) } } diff --git a/types/basetypes/number_value.go b/types/basetypes/number_value.go index a3799992..be1b45ad 100644 --- a/types/basetypes/number_value.go +++ b/types/basetypes/number_value.go @@ -13,7 +13,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/types/refinement" - tfrefinements "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" + tfrefinement "github.com/hashicorp/terraform-plugin-go/tftypes/refinement" ) var ( @@ -114,15 +114,15 @@ func (n NumberValue) ToTerraformValue(_ context.Context) (tftypes.Value, error) return tftypes.NewValue(tftypes.Number, tftypes.UnknownValue), nil } - unknownValRefinements := make(tfrefinements.Refinements, 0) + unknownValRefinements := make(tfrefinement.Refinements, 0) for _, refn := range n.refinements { switch refnVal := refn.(type) { case refinement.NotNull: - unknownValRefinements[tfrefinements.KeyNullness] = tfrefinements.NewNullness(false) + unknownValRefinements[tfrefinement.KeyNullness] = tfrefinement.NewNullness(false) case refinement.NumberLowerBound: - unknownValRefinements[tfrefinements.KeyNumberLowerBound] = tfrefinements.NewNumberLowerBound(refnVal.LowerBound(), refnVal.IsInclusive()) + unknownValRefinements[tfrefinement.KeyNumberLowerBound] = tfrefinement.NewNumberLowerBound(refnVal.LowerBound(), refnVal.IsInclusive()) case refinement.NumberUpperBound: - unknownValRefinements[tfrefinements.KeyNumberUpperBound] = tfrefinements.NewNumberUpperBound(refnVal.UpperBound(), refnVal.IsInclusive()) + unknownValRefinements[tfrefinement.KeyNumberUpperBound] = tfrefinement.NewNumberUpperBound(refnVal.UpperBound(), refnVal.IsInclusive()) } } unknownVal := tftypes.NewValue(tftypes.Number, tftypes.UnknownValue) From b4eee3470c40573e8ce3236f01cf440e964f1e3d Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Mon, 2 Dec 2024 09:04:04 -0500 Subject: [PATCH 23/39] set refinements --- types/basetypes/set_type.go | 29 +++- types/basetypes/set_type_test.go | 61 +++++++ types/basetypes/set_value.go | 185 +++++++++++++++++++++- types/basetypes/set_value_test.go | 255 ++++++++++++++++++++++++++++++ 4 files changed, 526 insertions(+), 4 deletions(-) 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) + } + }) + } +} From 093adf29280d3b6a7b5b0dbf27180dc5af463be1 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Mon, 2 Dec 2024 09:46:13 -0500 Subject: [PATCH 24/39] fix testtype --- internal/testing/testtypes/numberwithvalidateattribute.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/testing/testtypes/numberwithvalidateattribute.go b/internal/testing/testtypes/numberwithvalidateattribute.go index 09067e74..799db810 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.Equal(other) } func (t NumberTypeWithValidateAttributeWarning) ValueFromTerraform(ctx context.Context, in tftypes.Value) (attr.Value, error) { From 3fce8d59faf51197898be6c966364693c3d57cb1 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Mon, 2 Dec 2024 09:47:03 -0500 Subject: [PATCH 25/39] map refinements --- types/basetypes/map_type.go | 29 +++- types/basetypes/map_type_test.go | 61 +++++++ types/basetypes/map_value.go | 185 +++++++++++++++++++++- types/basetypes/map_value_test.go | 255 ++++++++++++++++++++++++++++++ 4 files changed, 526 insertions(+), 4 deletions(-) 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) + } + }) + } +} From cc81de38ac255d8ca05f317a0756d8b11f55a263 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Mon, 2 Dec 2024 09:59:09 -0500 Subject: [PATCH 26/39] order comment --- types/refinement/refinement.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/types/refinement/refinement.go b/types/refinement/refinement.go index b423aa3f..ff7067e9 100644 --- a/types/refinement/refinement.go +++ b/types/refinement/refinement.go @@ -51,10 +51,10 @@ const ( // This refinement is relevant for types.Int32, types.Int64, types.Float32, types.Float64, and types.Number. // // This Key is abstracted by the following refinements: - // - Int64LowerBound // - Int32LowerBound - // - Float64LowerBound + // - Int64LowerBound // - Float32LowerBound + // - Float64LowerBound // - NumberLowerBound KeyNumberLowerBound = Key(3) @@ -64,10 +64,10 @@ const ( // This refinement is relevant for types.Int32, types.Int64, types.Float32, types.Float64, and types.Number. // // This Key is abstracted by the following refinements: - // - Int64UpperBound // - Int32UpperBound - // - Float64UpperBound + // - Int64UpperBound // - Float32UpperBound + // - Float64UpperBound // - NumberUpperBound KeyNumberUpperBound = Key(4) From c6333be506aac0156a8fd5612fd250ba0ada6320 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Mon, 2 Dec 2024 11:02:02 -0500 Subject: [PATCH 27/39] string plan modifiers --- .../stringplanmodifier/will_have_prefix.go | 13 ++- .../will_have_prefix_test.go | 104 ++++++++++++++++++ .../stringplanmodifier/will_not_be_null.go | 17 ++- .../will_not_be_null_test.go | 98 +++++++++++++++++ 4 files changed, 226 insertions(+), 6 deletions(-) create mode 100644 resource/schema/stringplanmodifier/will_have_prefix_test.go create mode 100644 resource/schema/stringplanmodifier/will_not_be_null_test.go diff --git a/resource/schema/stringplanmodifier/will_have_prefix.go b/resource/schema/stringplanmodifier/will_have_prefix.go index 0c2bf6c0..e12f11ef 100644 --- a/resource/schema/stringplanmodifier/will_have_prefix.go +++ b/resource/schema/stringplanmodifier/will_have_prefix.go @@ -10,7 +10,14 @@ import ( "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" ) -// TODO: docs +// 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 a specified 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, @@ -22,11 +29,11 @@ type willHavePrefixModifier struct { } func (m willHavePrefixModifier) Description(_ context.Context) string { - return fmt.Sprintf("Promises the value will have the prefix %q once it becomes known", m.prefix) + 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 will have the prefix %q once it becomes known", m.prefix) + 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) { 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 index 917ded30..fbbf54fa 100644 --- a/resource/schema/stringplanmodifier/will_not_be_null.go +++ b/resource/schema/stringplanmodifier/will_not_be_null.go @@ -9,7 +9,18 @@ import ( "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" ) -// TODO: docs +// 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 evalutate during plan with a "not null" refinement on "id" +// count = examplecloud_thing.a.id != null ? 1 : 0 +// +// // .. resource config +// } func WillNotBeNull() planmodifier.String { return willNotBeNullModifier{} } @@ -17,11 +28,11 @@ func WillNotBeNull() planmodifier.String { type willNotBeNullModifier struct{} func (m willNotBeNullModifier) Description(_ context.Context) string { - return "Promises the value will not be null once it becomes known" + 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 will not be null once it becomes known" + 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) { 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) + } + }) + } +} From 18d08f98d718682e64f74291903ff79ce5ddef2c Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Mon, 2 Dec 2024 15:22:05 -0500 Subject: [PATCH 28/39] int64 plan modifiers --- .../int64planmodifier/will_be_at_least.go | 12 +- .../will_be_at_least_test.go | 104 +++++++++++++++++ .../int64planmodifier/will_be_at_most.go | 12 +- .../int64planmodifier/will_be_at_most_test.go | 104 +++++++++++++++++ .../int64planmodifier/will_be_between.go | 13 ++- .../int64planmodifier/will_be_between_test.go | 110 ++++++++++++++++++ .../int64planmodifier/will_not_be_null.go | 17 ++- .../will_not_be_null_test.go | 98 ++++++++++++++++ .../stringplanmodifier/will_have_prefix.go | 2 +- .../stringplanmodifier/will_not_be_null.go | 4 +- 10 files changed, 461 insertions(+), 15 deletions(-) create mode 100644 resource/schema/int64planmodifier/will_be_at_least_test.go create mode 100644 resource/schema/int64planmodifier/will_be_at_most_test.go create mode 100644 resource/schema/int64planmodifier/will_be_between_test.go create mode 100644 resource/schema/int64planmodifier/will_not_be_null_test.go diff --git a/resource/schema/int64planmodifier/will_be_at_least.go b/resource/schema/int64planmodifier/will_be_at_least.go index 1c50c218..6d220e19 100644 --- a/resource/schema/int64planmodifier/will_be_at_least.go +++ b/resource/schema/int64planmodifier/will_be_at_least.go @@ -10,7 +10,13 @@ import ( "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" ) -// TODO: docs +// 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, @@ -22,11 +28,11 @@ type willBeAtLeastModifier struct { } func (m willBeAtLeastModifier) Description(_ context.Context) string { - return fmt.Sprintf("Promises the value will be at least %d once it becomes known", m.min) + 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 will be at least %d once it becomes known", m.min) + 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) { 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 index f142c8ad..1064ab89 100644 --- a/resource/schema/int64planmodifier/will_be_at_most.go +++ b/resource/schema/int64planmodifier/will_be_at_most.go @@ -10,7 +10,13 @@ import ( "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" ) -// TODO: docs +// 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, @@ -22,11 +28,11 @@ type willBeAtMostModifier struct { } func (m willBeAtMostModifier) Description(_ context.Context) string { - return fmt.Sprintf("Promises the value will be at most %d once it becomes known", m.max) + 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 will be at most %d once it becomes known", m.max) + 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) { 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 index b3768a17..6bb5f564 100644 --- a/resource/schema/int64planmodifier/will_be_between.go +++ b/resource/schema/int64planmodifier/will_be_between.go @@ -10,7 +10,14 @@ import ( "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" ) -// TODO: docs +// 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, @@ -24,11 +31,11 @@ type willBeBetweenModifier struct { } func (m willBeBetweenModifier) Description(_ context.Context) string { - return fmt.Sprintf("Promises the value will be between %d and %d once it becomes known", m.min, m.max) + 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 will be between %d and %d once it becomes known", m.min, m.max) + 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) { 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 index a5a35888..5b7f1da4 100644 --- a/resource/schema/int64planmodifier/will_not_be_null.go +++ b/resource/schema/int64planmodifier/will_not_be_null.go @@ -9,7 +9,18 @@ import ( "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" ) -// TODO: docs +// 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 evalutate 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{} } @@ -17,11 +28,11 @@ func WillNotBeNull() planmodifier.Int64 { type willNotBeNullModifier struct{} func (m willNotBeNullModifier) Description(_ context.Context) string { - return "Promises the value will not be null once it becomes known" + 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 will not be null once it becomes known" + 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) { 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/stringplanmodifier/will_have_prefix.go b/resource/schema/stringplanmodifier/will_have_prefix.go index e12f11ef..492222ad 100644 --- a/resource/schema/stringplanmodifier/will_have_prefix.go +++ b/resource/schema/stringplanmodifier/will_have_prefix.go @@ -13,7 +13,7 @@ import ( // 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 a specified string prefix. +// - 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 diff --git a/resource/schema/stringplanmodifier/will_not_be_null.go b/resource/schema/stringplanmodifier/will_not_be_null.go index fbbf54fa..8b292cfc 100644 --- a/resource/schema/stringplanmodifier/will_not_be_null.go +++ b/resource/schema/stringplanmodifier/will_not_be_null.go @@ -16,8 +16,8 @@ import ( // and evaluate conditional logic in meta-arguments such as "count": // // resource "examplecloud_thing" "b" { -// // Will successfully evalutate during plan with a "not null" refinement on "id" -// count = examplecloud_thing.a.id != null ? 1 : 0 +// // Will successfully evalutate during plan with a "not null" refinement on "string_attribute" +// count = examplecloud_thing.a.string_attribute != null ? 1 : 0 // // // .. resource config // } From 6063b92eb6c2730821e9241c82b2968fe849ffbf Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Mon, 2 Dec 2024 15:33:42 -0500 Subject: [PATCH 29/39] int32 plan modifiers --- .../int32planmodifier/will_be_at_least.go | 50 ++++++++ .../will_be_at_least_test.go | 104 +++++++++++++++++ .../int32planmodifier/will_be_at_most.go | 50 ++++++++ .../int32planmodifier/will_be_at_most_test.go | 104 +++++++++++++++++ .../int32planmodifier/will_be_between.go | 55 +++++++++ .../int32planmodifier/will_be_between_test.go | 110 ++++++++++++++++++ .../int32planmodifier/will_not_be_null.go | 50 ++++++++ .../will_not_be_null_test.go | 98 ++++++++++++++++ 8 files changed, 621 insertions(+) create mode 100644 resource/schema/int32planmodifier/will_be_at_least.go create mode 100644 resource/schema/int32planmodifier/will_be_at_least_test.go create mode 100644 resource/schema/int32planmodifier/will_be_at_most.go create mode 100644 resource/schema/int32planmodifier/will_be_at_most_test.go create mode 100644 resource/schema/int32planmodifier/will_be_between.go create mode 100644 resource/schema/int32planmodifier/will_be_between_test.go create mode 100644 resource/schema/int32planmodifier/will_not_be_null.go create mode 100644 resource/schema/int32planmodifier/will_not_be_null_test.go 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..b203c534 --- /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 evalutate 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) + } + }) + } +} From 7fd8967626b9d87f9593245bda5477d102ad80f7 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Mon, 2 Dec 2024 15:43:05 -0500 Subject: [PATCH 30/39] float64 plan modifiers --- .../float64planmodifier/will_be_at_least.go | 50 ++++++++ .../will_be_at_least_test.go | 104 +++++++++++++++++ .../float64planmodifier/will_be_at_most.go | 50 ++++++++ .../will_be_at_most_test.go | 104 +++++++++++++++++ .../float64planmodifier/will_be_between.go | 55 +++++++++ .../will_be_between_test.go | 110 ++++++++++++++++++ .../float64planmodifier/will_not_be_null.go | 50 ++++++++ .../will_not_be_null_test.go | 98 ++++++++++++++++ 8 files changed, 621 insertions(+) create mode 100644 resource/schema/float64planmodifier/will_be_at_least.go create mode 100644 resource/schema/float64planmodifier/will_be_at_least_test.go create mode 100644 resource/schema/float64planmodifier/will_be_at_most.go create mode 100644 resource/schema/float64planmodifier/will_be_at_most_test.go create mode 100644 resource/schema/float64planmodifier/will_be_between.go create mode 100644 resource/schema/float64planmodifier/will_be_between_test.go create mode 100644 resource/schema/float64planmodifier/will_not_be_null.go create mode 100644 resource/schema/float64planmodifier/will_not_be_null_test.go 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..9ae4dc17 --- /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 evalutate 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) + } + }) + } +} From b52b2c81add2991b4f80fb5006f12fb427ac16cd Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Mon, 2 Dec 2024 15:46:15 -0500 Subject: [PATCH 31/39] float32 plan modifiers --- .../float32planmodifier/will_be_at_least.go | 50 ++++++++ .../will_be_at_least_test.go | 104 +++++++++++++++++ .../float32planmodifier/will_be_at_most.go | 50 ++++++++ .../will_be_at_most_test.go | 104 +++++++++++++++++ .../float32planmodifier/will_be_between.go | 55 +++++++++ .../will_be_between_test.go | 110 ++++++++++++++++++ .../float32planmodifier/will_not_be_null.go | 50 ++++++++ .../will_not_be_null_test.go | 98 ++++++++++++++++ 8 files changed, 621 insertions(+) create mode 100644 resource/schema/float32planmodifier/will_be_at_least.go create mode 100644 resource/schema/float32planmodifier/will_be_at_least_test.go create mode 100644 resource/schema/float32planmodifier/will_be_at_most.go create mode 100644 resource/schema/float32planmodifier/will_be_at_most_test.go create mode 100644 resource/schema/float32planmodifier/will_be_between.go create mode 100644 resource/schema/float32planmodifier/will_be_between_test.go create mode 100644 resource/schema/float32planmodifier/will_not_be_null.go create mode 100644 resource/schema/float32planmodifier/will_not_be_null_test.go 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..89308d13 --- /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 evalutate 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) + } + }) + } +} From b45f8af14d586546c46e9d32463180c3cdc63771 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Mon, 2 Dec 2024 15:58:13 -0500 Subject: [PATCH 32/39] number plan modifiers --- .../numberplanmodifier/will_be_at_least.go | 51 ++++++++ .../will_be_at_least_test.go | 105 +++++++++++++++++ .../numberplanmodifier/will_be_at_most.go | 51 ++++++++ .../will_be_at_most_test.go | 105 +++++++++++++++++ .../numberplanmodifier/will_be_between.go | 56 +++++++++ .../will_be_between_test.go | 111 ++++++++++++++++++ .../numberplanmodifier/will_not_be_null.go | 50 ++++++++ .../will_not_be_null_test.go | 99 ++++++++++++++++ 8 files changed, 628 insertions(+) create mode 100644 resource/schema/numberplanmodifier/will_be_at_least.go create mode 100644 resource/schema/numberplanmodifier/will_be_at_least_test.go create mode 100644 resource/schema/numberplanmodifier/will_be_at_most.go create mode 100644 resource/schema/numberplanmodifier/will_be_at_most_test.go create mode 100644 resource/schema/numberplanmodifier/will_be_between.go create mode 100644 resource/schema/numberplanmodifier/will_be_between_test.go create mode 100644 resource/schema/numberplanmodifier/will_not_be_null.go create mode 100644 resource/schema/numberplanmodifier/will_not_be_null_test.go 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..2e96efd0 --- /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 evalutate 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) + } + }) + } +} From 2a7b3ddec3a2ef78715d039113402d44d0bcb2af Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Mon, 2 Dec 2024 16:07:45 -0500 Subject: [PATCH 33/39] object and bool plan modifiers --- .../boolplanmodifier/will_not_be_null.go | 50 +++++++++ .../boolplanmodifier/will_not_be_null_test.go | 88 +++++++++++++++ .../objectplanmodifier/will_not_be_null.go | 50 +++++++++ .../will_not_be_null_test.go | 103 ++++++++++++++++++ 4 files changed, 291 insertions(+) create mode 100644 resource/schema/boolplanmodifier/will_not_be_null.go create mode 100644 resource/schema/boolplanmodifier/will_not_be_null_test.go create mode 100644 resource/schema/objectplanmodifier/will_not_be_null.go create mode 100644 resource/schema/objectplanmodifier/will_not_be_null_test.go 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..1cd552dc --- /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 evalutate 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/objectplanmodifier/will_not_be_null.go b/resource/schema/objectplanmodifier/will_not_be_null.go new file mode 100644 index 00000000..ad59f3ec --- /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 evalutate 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) + } + }) + } +} From 411df8d0bc2514a6b2ab6e20ae2eba09edbdc2c4 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Mon, 2 Dec 2024 16:52:42 -0500 Subject: [PATCH 34/39] list plan modifiers --- .../will_have_size_at_least.go | 50 ++++++++ .../will_have_size_at_least_test.go | 105 +++++++++++++++++ .../will_have_size_at_most.go | 50 ++++++++ .../will_have_size_at_most_test.go | 105 +++++++++++++++++ .../will_have_size_between.go | 55 +++++++++ .../will_have_size_between_test.go | 111 ++++++++++++++++++ .../listplanmodifier/will_not_be_null.go | 50 ++++++++ .../listplanmodifier/will_not_be_null_test.go | 99 ++++++++++++++++ 8 files changed, 625 insertions(+) create mode 100644 resource/schema/listplanmodifier/will_have_size_at_least.go create mode 100644 resource/schema/listplanmodifier/will_have_size_at_least_test.go create mode 100644 resource/schema/listplanmodifier/will_have_size_at_most.go create mode 100644 resource/schema/listplanmodifier/will_have_size_at_most_test.go create mode 100644 resource/schema/listplanmodifier/will_have_size_between.go create mode 100644 resource/schema/listplanmodifier/will_have_size_between_test.go create mode 100644 resource/schema/listplanmodifier/will_not_be_null.go create mode 100644 resource/schema/listplanmodifier/will_not_be_null_test.go 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..89407359 --- /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 evalutate 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) + } + }) + } +} From 972929ce595c5b1bbf0ec5d1cb8e08544300fc66 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Mon, 2 Dec 2024 17:02:02 -0500 Subject: [PATCH 35/39] map and set plan modifiers --- .../will_have_size_at_least.go | 50 ++++++++ .../will_have_size_at_least_test.go | 105 +++++++++++++++++ .../mapplanmodifier/will_have_size_at_most.go | 50 ++++++++ .../will_have_size_at_most_test.go | 105 +++++++++++++++++ .../mapplanmodifier/will_have_size_between.go | 55 +++++++++ .../will_have_size_between_test.go | 111 ++++++++++++++++++ .../mapplanmodifier/will_not_be_null.go | 50 ++++++++ .../mapplanmodifier/will_not_be_null_test.go | 99 ++++++++++++++++ .../will_have_size_at_least.go | 50 ++++++++ .../will_have_size_at_least_test.go | 105 +++++++++++++++++ .../setplanmodifier/will_have_size_at_most.go | 50 ++++++++ .../will_have_size_at_most_test.go | 105 +++++++++++++++++ .../setplanmodifier/will_have_size_between.go | 55 +++++++++ .../will_have_size_between_test.go | 111 ++++++++++++++++++ .../setplanmodifier/will_not_be_null.go | 50 ++++++++ .../setplanmodifier/will_not_be_null_test.go | 99 ++++++++++++++++ 16 files changed, 1250 insertions(+) create mode 100644 resource/schema/mapplanmodifier/will_have_size_at_least.go create mode 100644 resource/schema/mapplanmodifier/will_have_size_at_least_test.go create mode 100644 resource/schema/mapplanmodifier/will_have_size_at_most.go create mode 100644 resource/schema/mapplanmodifier/will_have_size_at_most_test.go create mode 100644 resource/schema/mapplanmodifier/will_have_size_between.go create mode 100644 resource/schema/mapplanmodifier/will_have_size_between_test.go create mode 100644 resource/schema/mapplanmodifier/will_not_be_null.go create mode 100644 resource/schema/mapplanmodifier/will_not_be_null_test.go create mode 100644 resource/schema/setplanmodifier/will_have_size_at_least.go create mode 100644 resource/schema/setplanmodifier/will_have_size_at_least_test.go create mode 100644 resource/schema/setplanmodifier/will_have_size_at_most.go create mode 100644 resource/schema/setplanmodifier/will_have_size_at_most_test.go create mode 100644 resource/schema/setplanmodifier/will_have_size_between.go create mode 100644 resource/schema/setplanmodifier/will_have_size_between_test.go create mode 100644 resource/schema/setplanmodifier/will_not_be_null.go create mode 100644 resource/schema/setplanmodifier/will_not_be_null_test.go 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..333e4c89 --- /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 evalutate 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/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..f7c8fad9 --- /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 evalutate 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) + } + }) + } +} From b59f0937c08d8682b6978d3db005f4f48005982a Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Mon, 2 Dec 2024 17:13:14 -0500 Subject: [PATCH 36/39] add tests for not null refinement --- internal/fwserver/attribute_validation.go | 3 +- .../fwserver/attribute_validation_test.go | 35 +++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/internal/fwserver/attribute_validation.go b/internal/fwserver/attribute_validation.go index bd24643a..83333c8d 100644 --- a/internal/fwserver/attribute_validation.go +++ b/internal/fwserver/attribute_validation.go @@ -140,11 +140,10 @@ func AttributeValidate(ctx context.Context, a fwschema.Attribute, req ValidateAt // Show deprecation warnings only for known values or unknown values with a "not null" refinement. if a.GetDeprecationMessage() != "" { if attributeConfig.IsUnknown() { - // If the attr.Value supports checking for refinements, we should check if the eventual known value will be not null. + // 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 { - // If the unknown value will eventually be not null, we return the deprecation message for the practitioner. resp.Diagnostics.AddAttributeWarning( req.AttributePath, "Attribute Deprecated", 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"), From 96d9ccf48fe7aad9c7433a578624e79be228a92c Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Tue, 3 Dec 2024 10:46:36 -0500 Subject: [PATCH 37/39] spelling --- resource/schema/boolplanmodifier/will_not_be_null.go | 2 +- resource/schema/float32planmodifier/will_not_be_null.go | 2 +- resource/schema/float64planmodifier/will_not_be_null.go | 2 +- resource/schema/int32planmodifier/will_not_be_null.go | 2 +- resource/schema/int64planmodifier/will_not_be_null.go | 2 +- resource/schema/listplanmodifier/will_not_be_null.go | 2 +- resource/schema/mapplanmodifier/will_not_be_null.go | 2 +- resource/schema/numberplanmodifier/will_not_be_null.go | 2 +- resource/schema/objectplanmodifier/will_not_be_null.go | 2 +- resource/schema/setplanmodifier/will_not_be_null.go | 2 +- resource/schema/stringplanmodifier/will_not_be_null.go | 2 +- 11 files changed, 11 insertions(+), 11 deletions(-) diff --git a/resource/schema/boolplanmodifier/will_not_be_null.go b/resource/schema/boolplanmodifier/will_not_be_null.go index 1cd552dc..f2a21155 100644 --- a/resource/schema/boolplanmodifier/will_not_be_null.go +++ b/resource/schema/boolplanmodifier/will_not_be_null.go @@ -16,7 +16,7 @@ import ( // and evaluate conditional logic in meta-arguments such as "count": // // resource "examplecloud_thing" "b" { -// // Will successfully evalutate during plan with a "not null" refinement on "bool_attribute" +// // Will successfully evaluate during plan with a "not null" refinement on "bool_attribute" // count = examplecloud_thing.a.bool_attribute != null ? 1 : 0 // // // .. resource config diff --git a/resource/schema/float32planmodifier/will_not_be_null.go b/resource/schema/float32planmodifier/will_not_be_null.go index 89308d13..ebe44003 100644 --- a/resource/schema/float32planmodifier/will_not_be_null.go +++ b/resource/schema/float32planmodifier/will_not_be_null.go @@ -16,7 +16,7 @@ import ( // and evaluate conditional logic in meta-arguments such as "count": // // resource "examplecloud_thing" "b" { -// // Will successfully evalutate during plan with a "not null" refinement on "float32_attribute" +// // Will successfully evaluate during plan with a "not null" refinement on "float32_attribute" // count = examplecloud_thing.a.float32_attribute != null ? 1 : 0 // // // .. resource config diff --git a/resource/schema/float64planmodifier/will_not_be_null.go b/resource/schema/float64planmodifier/will_not_be_null.go index 9ae4dc17..83f9879a 100644 --- a/resource/schema/float64planmodifier/will_not_be_null.go +++ b/resource/schema/float64planmodifier/will_not_be_null.go @@ -16,7 +16,7 @@ import ( // and evaluate conditional logic in meta-arguments such as "count": // // resource "examplecloud_thing" "b" { -// // Will successfully evalutate during plan with a "not null" refinement on "float64_attribute" +// // Will successfully evaluate during plan with a "not null" refinement on "float64_attribute" // count = examplecloud_thing.a.float64_attribute != null ? 1 : 0 // // // .. resource config diff --git a/resource/schema/int32planmodifier/will_not_be_null.go b/resource/schema/int32planmodifier/will_not_be_null.go index b203c534..aa6a54b5 100644 --- a/resource/schema/int32planmodifier/will_not_be_null.go +++ b/resource/schema/int32planmodifier/will_not_be_null.go @@ -16,7 +16,7 @@ import ( // and evaluate conditional logic in meta-arguments such as "count": // // resource "examplecloud_thing" "b" { -// // Will successfully evalutate during plan with a "not null" refinement on "int32_attribute" +// // Will successfully evaluate during plan with a "not null" refinement on "int32_attribute" // count = examplecloud_thing.a.int32_attribute != null ? 1 : 0 // // // .. resource config diff --git a/resource/schema/int64planmodifier/will_not_be_null.go b/resource/schema/int64planmodifier/will_not_be_null.go index 5b7f1da4..7818554e 100644 --- a/resource/schema/int64planmodifier/will_not_be_null.go +++ b/resource/schema/int64planmodifier/will_not_be_null.go @@ -16,7 +16,7 @@ import ( // and evaluate conditional logic in meta-arguments such as "count": // // resource "examplecloud_thing" "b" { -// // Will successfully evalutate during plan with a "not null" refinement on "int64_attribute" +// // Will successfully evaluate during plan with a "not null" refinement on "int64_attribute" // count = examplecloud_thing.a.int64_attribute != null ? 1 : 0 // // // .. resource config diff --git a/resource/schema/listplanmodifier/will_not_be_null.go b/resource/schema/listplanmodifier/will_not_be_null.go index 89407359..89b27c8b 100644 --- a/resource/schema/listplanmodifier/will_not_be_null.go +++ b/resource/schema/listplanmodifier/will_not_be_null.go @@ -16,7 +16,7 @@ import ( // and evaluate conditional logic in meta-arguments such as "count": // // resource "examplecloud_thing" "b" { -// // Will successfully evalutate during plan with a "not null" refinement on "list_attribute" +// // Will successfully evaluate during plan with a "not null" refinement on "list_attribute" // count = examplecloud_thing.a.list_attribute != null ? 1 : 0 // // // .. resource config diff --git a/resource/schema/mapplanmodifier/will_not_be_null.go b/resource/schema/mapplanmodifier/will_not_be_null.go index 333e4c89..75dc247d 100644 --- a/resource/schema/mapplanmodifier/will_not_be_null.go +++ b/resource/schema/mapplanmodifier/will_not_be_null.go @@ -16,7 +16,7 @@ import ( // and evaluate conditional logic in meta-arguments such as "count": // // resource "examplecloud_thing" "b" { -// // Will successfully evalutate during plan with a "not null" refinement on "map_attribute" +// // Will successfully evaluate during plan with a "not null" refinement on "map_attribute" // count = examplecloud_thing.a.map_attribute != null ? 1 : 0 // // // .. resource config diff --git a/resource/schema/numberplanmodifier/will_not_be_null.go b/resource/schema/numberplanmodifier/will_not_be_null.go index 2e96efd0..2592f6a0 100644 --- a/resource/schema/numberplanmodifier/will_not_be_null.go +++ b/resource/schema/numberplanmodifier/will_not_be_null.go @@ -16,7 +16,7 @@ import ( // and evaluate conditional logic in meta-arguments such as "count": // // resource "examplecloud_thing" "b" { -// // Will successfully evalutate during plan with a "not null" refinement on "number_attribute" +// // Will successfully evaluate during plan with a "not null" refinement on "number_attribute" // count = examplecloud_thing.a.number_attribute != null ? 1 : 0 // // // .. resource config diff --git a/resource/schema/objectplanmodifier/will_not_be_null.go b/resource/schema/objectplanmodifier/will_not_be_null.go index ad59f3ec..d0125483 100644 --- a/resource/schema/objectplanmodifier/will_not_be_null.go +++ b/resource/schema/objectplanmodifier/will_not_be_null.go @@ -16,7 +16,7 @@ import ( // and evaluate conditional logic in meta-arguments such as "count": // // resource "examplecloud_thing" "b" { -// // Will successfully evalutate during plan with a "not null" refinement on "object_attribute" +// // Will successfully evaluate during plan with a "not null" refinement on "object_attribute" // count = examplecloud_thing.a.object_attribute != null ? 1 : 0 // // // .. resource config diff --git a/resource/schema/setplanmodifier/will_not_be_null.go b/resource/schema/setplanmodifier/will_not_be_null.go index f7c8fad9..b00d3063 100644 --- a/resource/schema/setplanmodifier/will_not_be_null.go +++ b/resource/schema/setplanmodifier/will_not_be_null.go @@ -16,7 +16,7 @@ import ( // and evaluate conditional logic in meta-arguments such as "count": // // resource "examplecloud_thing" "b" { -// // Will successfully evalutate during plan with a "not null" refinement on "set_attribute" +// // Will successfully evaluate during plan with a "not null" refinement on "set_attribute" // count = examplecloud_thing.a.set_attribute != null ? 1 : 0 // // // .. resource config diff --git a/resource/schema/stringplanmodifier/will_not_be_null.go b/resource/schema/stringplanmodifier/will_not_be_null.go index 8b292cfc..40ab997b 100644 --- a/resource/schema/stringplanmodifier/will_not_be_null.go +++ b/resource/schema/stringplanmodifier/will_not_be_null.go @@ -16,7 +16,7 @@ import ( // and evaluate conditional logic in meta-arguments such as "count": // // resource "examplecloud_thing" "b" { -// // Will successfully evalutate during plan with a "not null" refinement on "string_attribute" +// // Will successfully evaluate during plan with a "not null" refinement on "string_attribute" // count = examplecloud_thing.a.string_attribute != null ? 1 : 0 // // // .. resource config From a2d4066020127be0d0afe2b71b36d28774ada51e Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Tue, 3 Dec 2024 10:54:12 -0500 Subject: [PATCH 38/39] switch to commit hash --- go.mod | 4 +--- go.sum | 2 ++ 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index c3686a6a..b44594f6 100644 --- a/go.mod +++ b/go.mod @@ -4,11 +4,9 @@ go 1.22.0 toolchain go1.22.7 -replace github.com/hashicorp/terraform-plugin-go => /Users/austin.valle/code/terraform-plugin-go - 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 ) diff --git a/go.sum b/go.sum index 2bd0287d..ab5b2744 100644 --- a/go.sum +++ b/go.sum @@ -15,6 +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.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= From a85b9d5263f0ad07a05643f730134f35b43614bc Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Tue, 3 Dec 2024 11:06:59 -0500 Subject: [PATCH 39/39] fix custom type implementations --- internal/testing/testtypes/numberwithvalidateattribute.go | 4 ++-- internal/testing/testtypes/stringwithvalidateattribute.go | 4 ++-- internal/testing/testtypes/stringwithvalidateparameter.go | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/testing/testtypes/numberwithvalidateattribute.go b/internal/testing/testtypes/numberwithvalidateattribute.go index 799db810..854c3ce0 100644 --- a/internal/testing/testtypes/numberwithvalidateattribute.go +++ b/internal/testing/testtypes/numberwithvalidateattribute.go @@ -92,7 +92,7 @@ func (t NumberTypeWithValidateAttributeWarning) Equal(o attr.Type) bool { if !ok { return false } - return t.Equal(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 872b0fe5..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.Equal(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.Equal(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 380ba516..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.Equal(other) + return v.InternalString.Equal(other.InternalString) } func (v StringValueWithValidateParameterError) IsNull() bool {