Skip to content

Commit 1405baa

Browse files
committed
types: Deprecate attr.Value type Null, Unknown, and Value fields
Reference: #447 When the framework type system was originally being developed, the value types were introduced with exported fields which also served as the internal details of whether a value was null, unknown, or a known value of a friendlier Go type. It was known that there was the potential for issues, but the simplified developer experience seemed to outweigh the potential for developer issues. Fast forward a few months, this decision appears to have two consequences that the framework maintainers hear about across various forums. One issue is that the value types directly expose their internal implementation details and support the three states of a Terraform type value: being null, unknown, or a known value. Only one state should ever be set, but provider developers can make a value that is any combination of those states. This makes the framework behavior potentially indeterminate from the provider developer perspective whether, for example, a null AND unknown value becomes null OR unknown as it works its way through the framework. ```go In this example, the logic has created an invalid null AND unknown value: type ThingResourceModel struct{ Computed types.String `tfsdk:"computed"` } func (r ThingResource) Create(ctx context.Context, req resource.CreateResource, resp *resource.CreateResponse) { var data ThingResourceModel resp.Diagnostics.Append(req.Plan.Get(ctx, &data)) tflog.Trace(ctx, "Data Values", map[string]any{ // Unknown value: types.String{Null: false, Unknown: true, Value: ""} "computed": plan.Computed, }) // Maybe some external API responses here, but showing hardcoded updates for // brevity. This will make the value invalid by enabling Null without // disabling Unknown. data.Computed.Null = true tflog.Trace(ctx, "Data Values", map[string]any{ // Invalid value: types.String{Null: true, Unknown: true, Value: ""} "computed": data.Computed, }) // The invalid value will be either null or unknown, depending on the // type implementation. If unknown, Terraform will error, since unknown // values are never allowed in state. resp.Diagnostics.Append(resp.State.Set(ctx, &data)) } ``` Another issue is that the default (zero-value) state for an "unset" value type turns into a known value, which is confusing since these values explicitly support being null. This causes Terraform errors which would surface to practitioners (especially when untested) that provider developers then have to troubleshoot the error message containing Terraform's type system details, potentially discover the reason why it is happening by looking at the framework type source code, then figure out a workable solution. It's not intuitive. ```go type ThingResourceModel struct{ // let's assume this is left unconfigured (null in config and plan) Optional types.String `tfsdk:"optional"` } func (r ThingResource) Create(ctx context.Context, req resource.CreateResource, resp *resource.CreateResponse) { // Providers can opt to use a single variable that is updated based on an // external response, however that logic can be more difficult sometimes, // so it can be easier to split them. Showing the split way to exemplify // the "unset" problem. var plan, state ThingResourceModel resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)) tflog.Trace(ctx, "Plan Values", map[string]any{ // Null value: types.String{Null: true, Unknown: false, Value: ""} "optional": plan.Optional, }) // Maybe some external API responses here, but intentionally not // doing any state.Optional setting, which might happen if the // external response for that data was null for example. tflog.Trace(ctx, "State Values", map[string]any{ // Zero-value: types.String{Null: false, Unknown: false, Value: ""} "optional": state.Optional, }) // The state zero-value will later cause Terraform to error, such as: // Error: Provider produced inconsistent result after apply // ... expected cty.NullVal(cty.String), got cty.StringVal("") // Since the plan value said it would be null. resp.Diagnostics.Append(resp.State.Set(ctx, &state)) } ``` This deprecation of the fields in preference of functions and methods aims to unexport the internal details and treat the value types as immutable once they are created. When provider developers switch over to the new model, any errant changes to the deprecated exported fields will have no effect. A future version will remove the exported fields entirely.
1 parent ef5183a commit 1405baa

File tree

6 files changed

+706
-60
lines changed

6 files changed

+706
-60
lines changed

.changelog/pending.txt

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
```release-note:note
2+
types: The `Bool` type `Null`, `Unknown`, and `Value` fields have been deprecated in preference of the `NullBool()`, `UnknownBool()`, and `ValueBool()` creation functions and `BoolValue()`, `IsNull()`, and `IsUnknown()` methods. The fields will be removed in a future release.
3+
```
4+
5+
```release-note:enhancement
6+
types: Added `NullBool()`, `UnknownBool()`, `ValueBool` functions, which create immutable `Bool` values
7+
```
8+
9+
```release-note:enhancement
10+
types: Added `Bool` type `BoolValue()` method, which returns the `bool` of the known value or `false` if null or unknown
11+
```

internal/reflect/primitive_test.go

+1-3
Original file line numberDiff line numberDiff line change
@@ -160,9 +160,7 @@ func TestFromBool(t *testing.T) {
160160
val: true,
161161
typ: testtypes.BoolTypeWithValidateWarning{},
162162
expected: testtypes.Bool{
163-
Bool: types.Bool{
164-
Value: true,
165-
},
163+
Bool: types.ValueBool(true),
166164
CreatedBy: testtypes.BoolTypeWithValidateWarning{},
167165
},
168166
expectedDiags: diag.Diagnostics{

internal/testing/types/bool.go

+4-12
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,13 @@ func (t BoolType) TerraformType(_ context.Context) tftypes.Type {
4141
func (t BoolType) ValueFromTerraform(ctx context.Context, in tftypes.Value) (attr.Value, error) {
4242
if in.IsNull() {
4343
return Bool{
44-
Bool: types.Bool{Null: true},
44+
Bool: types.NullBool(),
4545
CreatedBy: t,
4646
}, nil
4747
}
4848
if !in.IsKnown() {
4949
return Bool{
50-
Bool: types.Bool{Unknown: true},
50+
Bool: types.UnknownBool(),
5151
CreatedBy: t,
5252
}, nil
5353
}
@@ -56,7 +56,7 @@ func (t BoolType) ValueFromTerraform(ctx context.Context, in tftypes.Value) (att
5656
if err != nil {
5757
return nil, err
5858
}
59-
return Bool{Bool: types.Bool{Value: b}, CreatedBy: t}, nil
59+
return Bool{Bool: types.ValueBool(b), CreatedBy: t}, nil
6060
}
6161

6262
// ValueType returns the Value type.
@@ -91,13 +91,5 @@ func (b Bool) IsUnknown() bool {
9191
}
9292

9393
func (b Bool) String() string {
94-
if b.Bool.IsUnknown() {
95-
return attr.UnknownValueString
96-
}
97-
98-
if b.Bool.IsNull() {
99-
return attr.NullValueString
100-
}
101-
102-
return fmt.Sprintf("%t", b.Value)
94+
return b.Bool.String()
10395
}

types/bool.go

+122-13
Original file line numberDiff line numberDiff line change
@@ -12,37 +12,113 @@ var (
1212
_ attr.Value = Bool{}
1313
)
1414

15+
// NullBool creates a Bool with a null value. Determine whether the value is
16+
// null via the Bool type IsNull method.
17+
//
18+
// Setting the deprecated Bool type Null, Unknown, or Value fields after
19+
// creating a Bool with this function has no effect.
20+
func NullBool() Bool {
21+
return Bool{
22+
state: valueStateNull,
23+
}
24+
}
25+
26+
// UnknownBool creates a Bool with an unknown value. Determine whether the
27+
// value is unknown via the Bool type IsUnknown method.
28+
//
29+
// Setting the deprecated Bool type Null, Unknown, or Value fields after
30+
// creating a Bool with this function has no effect.
31+
func UnknownBool() Bool {
32+
return Bool{
33+
state: valueStateUnknown,
34+
}
35+
}
36+
37+
// ValueBool creates a Bool with a known value. Access the value via the Bool
38+
// type BoolValue method.
39+
//
40+
// Setting the deprecated Bool type Null, Unknown, or Value fields after
41+
// creating a Bool with this function has no effect.
42+
func ValueBool(value bool) Bool {
43+
return Bool{
44+
state: valueStateKnown,
45+
value: value,
46+
}
47+
}
48+
1549
func boolValueFromTerraform(ctx context.Context, in tftypes.Value) (attr.Value, error) {
1650
if in.IsNull() {
1751
return Bool{
18-
Null: true,
52+
Null: true,
53+
state: valueStateDeprecated,
1954
}, nil
2055
}
2156
if !in.IsKnown() {
2257
return Bool{
2358
Unknown: true,
59+
state: valueStateDeprecated,
2460
}, nil
2561
}
2662
var b bool
2763
err := in.As(&b)
2864
if err != nil {
2965
return nil, err
3066
}
31-
return Bool{Value: b}, nil
67+
return Bool{
68+
Value: b,
69+
state: valueStateDeprecated,
70+
}, nil
3271
}
3372

3473
// Bool represents a boolean value.
3574
type Bool struct {
3675
// Unknown will be true if the value is not yet known.
76+
//
77+
// If the Bool was created with the BoolValue, NullBool, or UnknownBool
78+
// functions, changing this field has no effect.
79+
//
80+
// Deprecated: Use the [UnknownBool] function to create an unknown Bool
81+
// value or use the [IsUnknown] method to determine whether the Bool value
82+
// is unknown instead.
3783
Unknown bool
3884

3985
// Null will be true if the value was not set, or was explicitly set to
4086
// null.
87+
//
88+
// If the Bool was created with the BoolValue, NullBool, or UnknownBool
89+
// functions, changing this field has no effect.
90+
//
91+
// Deprecated: Use the [NullBool] function to create a null Bool value or
92+
// use the [IsNull] method to determine whether the Bool value is null
93+
// instead.
4194
Null bool
4295

4396
// Value contains the set value, as long as Unknown and Null are both
4497
// false.
98+
//
99+
// If the Bool was created with the BoolValue, NullBool, or UnknownBool
100+
// functions, changing this field has no effect.
101+
//
102+
// Deprecated: Use the [BoolValue] function to create a known Bool value or
103+
// use the [BoolValue] method to retrieve the Bool value instead.
45104
Value bool
105+
106+
// state represents whether the value is null, unknown, or known. The
107+
// zero-value for this field is null.
108+
state valueState
109+
110+
// value contains the known value, if not null or unknown.
111+
value bool
112+
}
113+
114+
// BoolValue returns the known bool value. If Bool is null or unknown, returns
115+
// false.
116+
func (b Bool) BoolValue() bool {
117+
if b.state == valueStateDeprecated {
118+
return b.Value
119+
}
120+
121+
return b.value
46122
}
47123

48124
// Type returns a BoolType.
@@ -52,16 +128,31 @@ func (b Bool) Type(_ context.Context) attr.Type {
52128

53129
// ToTerraformValue returns the data contained in the Bool as a tftypes.Value.
54130
func (b Bool) ToTerraformValue(_ context.Context) (tftypes.Value, error) {
55-
if b.Null {
131+
switch b.state {
132+
case valueStateDeprecated:
133+
if b.Null {
134+
return tftypes.NewValue(tftypes.Bool, nil), nil
135+
}
136+
if b.Unknown {
137+
return tftypes.NewValue(tftypes.Bool, tftypes.UnknownValue), nil
138+
}
139+
if err := tftypes.ValidateValue(tftypes.Bool, b.Value); err != nil {
140+
return tftypes.NewValue(tftypes.Bool, tftypes.UnknownValue), err
141+
}
142+
return tftypes.NewValue(tftypes.Bool, b.Value), nil
143+
case valueStateKnown:
144+
if err := tftypes.ValidateValue(tftypes.Bool, b.value); err != nil {
145+
return tftypes.NewValue(tftypes.Bool, tftypes.UnknownValue), err
146+
}
147+
148+
return tftypes.NewValue(tftypes.Bool, b.value), nil
149+
case valueStateNull:
56150
return tftypes.NewValue(tftypes.Bool, nil), nil
57-
}
58-
if b.Unknown {
151+
case valueStateUnknown:
59152
return tftypes.NewValue(tftypes.Bool, tftypes.UnknownValue), nil
153+
default:
154+
panic(fmt.Sprintf("unhandled Bool state in ToTerraformValue: %s", b.state))
60155
}
61-
if err := tftypes.ValidateValue(tftypes.Bool, b.Value); err != nil {
62-
return tftypes.NewValue(tftypes.Bool, tftypes.UnknownValue), err
63-
}
64-
return tftypes.NewValue(tftypes.Bool, b.Value), nil
65156
}
66157

67158
// Equal returns true if `other` is a *Bool and has the same value as `b`.
@@ -70,6 +161,12 @@ func (b Bool) Equal(other attr.Value) bool {
70161
if !ok {
71162
return false
72163
}
164+
if b.state != o.state {
165+
return false
166+
}
167+
if b.state == valueStateKnown {
168+
return b.value == o.value
169+
}
73170
if b.Unknown != o.Unknown {
74171
return false
75172
}
@@ -81,25 +178,37 @@ func (b Bool) Equal(other attr.Value) bool {
81178

82179
// IsNull returns true if the Bool represents a null value.
83180
func (b Bool) IsNull() bool {
84-
return b.Null
181+
if b.state == valueStateNull {
182+
return true
183+
}
184+
185+
return b.state == valueStateDeprecated && b.Null
85186
}
86187

87188
// IsUnknown returns true if the Bool represents a currently unknown value.
88189
func (b Bool) IsUnknown() bool {
89-
return b.Unknown
190+
if b.state == valueStateUnknown {
191+
return true
192+
}
193+
194+
return b.state == valueStateDeprecated && b.Unknown
90195
}
91196

92197
// String returns a human-readable representation of the Bool value.
93198
// The string returned here is not protected by any compatibility guarantees,
94199
// and is intended for logging and error reporting.
95200
func (b Bool) String() string {
96-
if b.Unknown {
201+
if b.IsUnknown() {
97202
return attr.UnknownValueString
98203
}
99204

100-
if b.Null {
205+
if b.IsNull() {
101206
return attr.NullValueString
102207
}
103208

209+
if b.state == valueStateKnown {
210+
return fmt.Sprintf("%t", b.value)
211+
}
212+
104213
return fmt.Sprintf("%t", b.Value)
105214
}

0 commit comments

Comments
 (0)