diff --git a/.changes/unreleased/ENHANCEMENTS-20250729-112748.yaml b/.changes/unreleased/ENHANCEMENTS-20250729-112748.yaml new file mode 100644 index 000000000..3dbd2f6f0 --- /dev/null +++ b/.changes/unreleased/ENHANCEMENTS-20250729-112748.yaml @@ -0,0 +1,5 @@ +kind: ENHANCEMENTS +body: 'tftypes: `tftypes.Value.IsFullyNull()` allows SDKs to determine when a value is null or consists of only null elements and attributes.' +time: 2025-07-29T11:27:48.486954-04:00 +custom: + Issue: "541" diff --git a/tftypes/value.go b/tftypes/value.go index 63570211f..bf9bd1ab7 100644 --- a/tftypes/value.go +++ b/tftypes/value.go @@ -574,6 +574,46 @@ func (val Value) IsNull() bool { return val.value == nil } +// IsFullyNull returns true if the Value is null or if the Value is an +// aggregate that consists of only fully null elements and attributes. +func (val Value) IsFullyNull() bool { + if val.IsNull() { + return true + } + + switch val.Type().(type) { + case primitive: + return false // already checked IsNull() and not an aggregate + + case List, Set, Tuple: + sliceVal, ok := val.value.([]Value) + if !ok { + panic(fmt.Sprintf("impossible type assertion failure: %T to slice", val)) + } + for _, v := range sliceVal { + if !v.IsFullyNull() { + return false + } + } + return true + + case Map, Object: + mapVal, ok := val.value.(map[string]Value) + if !ok { + panic(fmt.Sprintf("impossible type assertion failure: %T to map", val)) + } + for _, v := range mapVal { + if !v.IsFullyNull() { + return false + } + } + return true + + default: + panic(fmt.Sprintf("unknown type %T", val.Type())) + } +} + // MarshalMsgPack returns a msgpack representation of the Value. This is used // for constructing tfprotov5.DynamicValues. // diff --git a/tftypes/value_test.go b/tftypes/value_test.go index 5d4aaf4a1..f2ef8d8ec 100644 --- a/tftypes/value_test.go +++ b/tftypes/value_test.go @@ -723,6 +723,198 @@ func TestValueIsKnown(t *testing.T) { } } +func TestValueIsNull(t *testing.T) { + t.Parallel() + type testCase struct { + value Value + expectedIsNull bool + expectedIsFullyNull bool + } + + simpleObjectTyp := Object{ + AttributeTypes: map[string]Type{ + "capacity": Number, + }, + OptionalAttributes: map[string]struct{}{ + "capacity": {}, + }, + } + + simpleListTyp := List{ + ElementType: String, + } + + simpleSetTyp := Set{ + ElementType: String, + } + + networkTyp := Object{ + AttributeTypes: map[string]Type{ + "name": String, + "speed": Number, + "labels": simpleListTyp, + }, + } + + objectTyp := Object{ + AttributeTypes: map[string]Type{ + "id": String, + "network": networkTyp, + }, + } + + listTyp := List{ + ElementType: networkTyp, + } + + tests := map[string]testCase{ + "primitive": { + value: NewValue(Number, 990), + expectedIsNull: false, + expectedIsFullyNull: false, + }, + "primitive-null": { + value: NewValue(Number, nil), + expectedIsNull: true, + expectedIsFullyNull: true, + }, + "dynamic": { + value: NewValue(DynamicPseudoType, "hello"), + expectedIsNull: false, + expectedIsFullyNull: false, + }, + "dynamic-null": { + value: NewValue(DynamicPseudoType, nil), + expectedIsNull: true, + expectedIsFullyNull: true, + }, + "simple-object": { + value: NewValue(simpleObjectTyp, map[string]Value{"capacity": NewValue(Number, 4096)}), + expectedIsNull: false, + expectedIsFullyNull: false, + }, + "simple-object-null": { + value: NewValue(simpleObjectTyp, nil), + expectedIsNull: true, + expectedIsFullyNull: true, + }, + "simple-object-with-empty-attributes-map": { + value: NewValue(simpleObjectTyp, map[string]Value{}), + expectedIsNull: false, + expectedIsFullyNull: true, + }, + "simple-object-with-nil-primitive": { + value: NewValue(simpleObjectTyp, map[string]Value{"capacity": NewValue(Number, nil)}), + expectedIsNull: false, + expectedIsFullyNull: true, + }, + "object-with-no-nils": { + value: NewValue(objectTyp, map[string]Value{ + "id": NewValue(String, "#00decaf"), + "network": NewValue(networkTyp, map[string]Value{ + "name": NewValue(String, "eth0"), + "speed": NewValue(Number, 1000000000), + "labels": NewValue(simpleListTyp, []Value{ + NewValue(String, "connected"), + }), + }), + }), + expectedIsNull: false, + expectedIsFullyNull: false, + }, + "object-with-shallow-nils": { + value: NewValue(objectTyp, map[string]Value{ + "id": NewValue(String, nil), + "network": NewValue(networkTyp, nil), + }), + expectedIsNull: false, + expectedIsFullyNull: true, + }, + "object-with-deep-nils": { + value: NewValue(objectTyp, map[string]Value{ + "id": NewValue(String, nil), + "network": NewValue(networkTyp, map[string]Value{ + "name": NewValue(String, nil), + "speed": NewValue(Number, nil), + "labels": NewValue(simpleListTyp, []Value{ + NewValue(String, nil), + }), + }), + }), + expectedIsNull: false, + expectedIsFullyNull: true, + }, + "object-with-some-nils": { + value: NewValue(objectTyp, map[string]Value{ + "id": NewValue(String, nil), + "network": NewValue(networkTyp, map[string]Value{ + "name": NewValue(String, nil), + "speed": NewValue(Number, 1000), + "labels": NewValue(simpleListTyp, []Value{ + NewValue(String, nil), + }), + }), + }), + expectedIsNull: false, + expectedIsFullyNull: false, + }, + "simple-list-with-no-nils": { + value: NewValue(simpleListTyp, []Value{ + NewValue(String, "restarting"), + }), + expectedIsNull: false, + expectedIsFullyNull: false, + }, + "simple-list-with-some-nils": { + value: NewValue(simpleListTyp, []Value{ + NewValue(String, "east-mars"), + NewValue(String, "south-mars"), + NewValue(String, nil), + }), + expectedIsNull: false, + expectedIsFullyNull: false, + }, + "simple-list-with-shallow-nils": { + value: NewValue(simpleListTyp, []Value{ + NewValue(String, nil), + }), + expectedIsNull: false, + expectedIsFullyNull: true, + }, + "list-with-deep-nils": { + value: NewValue(listTyp, []Value{ + NewValue(networkTyp, nil), + }), + expectedIsNull: false, + expectedIsFullyNull: true, + }, + "simple-set-with-shallow-nils": { + value: NewValue(simpleSetTyp, []Value{ + NewValue(String, nil), + }), + expectedIsNull: false, + expectedIsFullyNull: true, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + actualIsNull := test.value.IsNull() + actualIsFullyNull := test.value.IsFullyNull() + + if test.expectedIsNull != actualIsNull { + t.Errorf("expected IsNull() to be %v; actual: %v", test.expectedIsNull, actualIsNull) + } + + if test.expectedIsFullyNull != actualIsFullyNull { + t.Errorf("expected IsFullyNull() to be %v; actual: %v", test.expectedIsNull, actualIsNull) + } + }) + } +} + func TestValueEqual(t *testing.T) { t.Parallel() type testCase struct {