From 53ca524e117bdd4aeaa02ddfdb40f7db42a412a9 Mon Sep 17 00:00:00 2001 From: Baraa Basata Date: Fri, 18 Jul 2025 11:40:23 -0400 Subject: [PATCH 1/8] tftypes: add tftypes.IsFullyNull() --- tftypes/value.go | 31 +++++++++++++ tftypes/value_test.go | 100 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+) diff --git a/tftypes/value.go b/tftypes/value.go index 63570211f..6a50a90b4 100644 --- a/tftypes/value.go +++ b/tftypes/value.go @@ -574,6 +574,37 @@ func (val Value) IsNull() bool { return val.value == nil } +func (val Value) IsFullyNull() bool { + switch val.Type().(type) { + case primitive: + return val.IsNull() + case List, Set, Tuple: + sliceVal, ok := val.value.([]Value) + if !ok { + return true + } + for _, v := range sliceVal { + if !v.IsFullyNull() { + return false + } + } + return true + case Map, Object: + mapVal, ok := val.value.(map[string]Value) + if !ok { + return true + } + 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..40641c8df 100644 --- a/tftypes/value_test.go +++ b/tftypes/value_test.go @@ -723,6 +723,106 @@ 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": {}, + }, + } + + networkTyp := Object{ + AttributeTypes: map[string]Type{ + "name": String, + "speed": Number, + }, + } + objectTyp := Object{ + AttributeTypes: map[string]Type{ + "id": String, + "network": networkTyp, + }, + } + + tests := map[string]testCase{ + "nil-object": { + 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, + }, + "simple-object-with-non-nil-primitive": { + value: NewValue(simpleObjectTyp, map[string]Value{"capacity": NewValue(Number, 4096)}), + expectedIsNull: false, + expectedIsFullyNull: false, + }, + "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), + }), + }), + 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), + }), + }), + 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 { From 3e2ce43e72e891a4e14c059b4f825b5b6206d587 Mon Sep 17 00:00:00 2001 From: Baraa Basata Date: Fri, 18 Jul 2025 20:20:51 -0400 Subject: [PATCH 2/8] Tests for List types --- tftypes/value_test.go | 42 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/tftypes/value_test.go b/tftypes/value_test.go index 40641c8df..83e9293f6 100644 --- a/tftypes/value_test.go +++ b/tftypes/value_test.go @@ -740,12 +740,18 @@ func TestValueIsNull(t *testing.T) { }, } + simpleListTyp := List{ + ElementType: String, + } + networkTyp := Object{ AttributeTypes: map[string]Type{ - "name": String, - "speed": Number, + "name": String, + "speed": Number, + "labels": simpleListTyp, }, } + objectTyp := Object{ AttributeTypes: map[string]Type{ "id": String, @@ -753,6 +759,10 @@ func TestValueIsNull(t *testing.T) { }, } + listTyp := List{ + ElementType: networkTyp, + } + tests := map[string]testCase{ "nil-object": { value: NewValue(simpleObjectTyp, nil), @@ -780,6 +790,9 @@ func TestValueIsNull(t *testing.T) { "network": NewValue(networkTyp, map[string]Value{ "name": NewValue(String, "eth0"), "speed": NewValue(Number, 1000000000), + "labels": NewValue(simpleListTyp, []Value{ + NewValue(String, "connected"), + }), }), }), expectedIsNull: false, @@ -799,16 +812,41 @@ func TestValueIsNull(t *testing.T) { "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, }, + "simple-list-with-no-nils": { + value: NewValue(simpleListTyp, []Value{ + NewValue(String, "restarting"), + }), + 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, + }, } for name, test := range tests { t.Run(name, func(t *testing.T) { t.Parallel() + actualIsNull := test.value.IsNull() actualIsFullyNull := test.value.IsFullyNull() From ba91e6a5361b1c1dd15deb3d2505d64bd0e59445 Mon Sep 17 00:00:00 2001 From: Baraa Basata Date: Fri, 18 Jul 2025 20:27:10 -0400 Subject: [PATCH 3/8] Add tests for partially null and for sets --- tftypes/value_test.go | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tftypes/value_test.go b/tftypes/value_test.go index 83e9293f6..726d06a6c 100644 --- a/tftypes/value_test.go +++ b/tftypes/value_test.go @@ -744,6 +744,10 @@ func TestValueIsNull(t *testing.T) { ElementType: String, } + simpleSetTyp := Set{ + ElementType: String, + } + networkTyp := Object{ AttributeTypes: map[string]Type{ "name": String, @@ -820,6 +824,20 @@ func TestValueIsNull(t *testing.T) { 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"), @@ -827,6 +845,15 @@ func TestValueIsNull(t *testing.T) { 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), @@ -841,6 +868,13 @@ func TestValueIsNull(t *testing.T) { 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 { From 742e1578be556d6d544d04ed79b29373f20a8c38 Mon Sep 17 00:00:00 2001 From: Baraa Basata Date: Mon, 28 Jul 2025 13:42:57 -0400 Subject: [PATCH 4/8] Add comment --- tftypes/value.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tftypes/value.go b/tftypes/value.go index 6a50a90b4..e81fd49c6 100644 --- a/tftypes/value.go +++ b/tftypes/value.go @@ -574,6 +574,8 @@ 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 { switch val.Type().(type) { case primitive: From 948cf8c76dad8eff5ce3e84f8eb5108579367be7 Mon Sep 17 00:00:00 2001 From: Baraa Basata Date: Tue, 29 Jul 2025 11:19:12 -0400 Subject: [PATCH 5/8] refactor --- tftypes/value.go | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/tftypes/value.go b/tftypes/value.go index e81fd49c6..6b68ee827 100644 --- a/tftypes/value.go +++ b/tftypes/value.go @@ -577,31 +577,32 @@ func (val Value) IsNull() bool { // 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 val.IsNull() + return false // already checked IsNull() and not an aggregate + case List, Set, Tuple: - sliceVal, ok := val.value.([]Value) - if !ok { - return true - } + sliceVal := val.value.([]Value) for _, v := range sliceVal { if !v.IsFullyNull() { return false } } return true + case Map, Object: - mapVal, ok := val.value.(map[string]Value) - if !ok { - return true - } + mapVal := val.value.(map[string]Value) for _, v := range mapVal { if !v.IsFullyNull() { return false } } return true + default: panic(fmt.Sprintf("unknown type %T", val.Type())) } From 6f855282f24237ea61e6c1400b11963d96b369dd Mon Sep 17 00:00:00 2001 From: Baraa Basata Date: Tue, 29 Jul 2025 11:25:16 -0400 Subject: [PATCH 6/8] test cases for primitives and dynamic --- tftypes/value_test.go | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/tftypes/value_test.go b/tftypes/value_test.go index 726d06a6c..f2ef8d8ec 100644 --- a/tftypes/value_test.go +++ b/tftypes/value_test.go @@ -768,7 +768,32 @@ func TestValueIsNull(t *testing.T) { } tests := map[string]testCase{ - "nil-object": { + "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, @@ -783,11 +808,6 @@ func TestValueIsNull(t *testing.T) { expectedIsNull: false, expectedIsFullyNull: true, }, - "simple-object-with-non-nil-primitive": { - value: NewValue(simpleObjectTyp, map[string]Value{"capacity": NewValue(Number, 4096)}), - expectedIsNull: false, - expectedIsFullyNull: false, - }, "object-with-no-nils": { value: NewValue(objectTyp, map[string]Value{ "id": NewValue(String, "#00decaf"), From adedb67471190844d7acba9f306e956de97b80ef Mon Sep 17 00:00:00 2001 From: Baraa Basata Date: Tue, 29 Jul 2025 11:27:58 -0400 Subject: [PATCH 7/8] add changelog --- .changes/unreleased/ENHANCEMENTS-20250729-112748.yaml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changes/unreleased/ENHANCEMENTS-20250729-112748.yaml 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" From 39729d616bc362d76f9c7b501cf078f4390130cf Mon Sep 17 00:00:00 2001 From: Baraa Basata Date: Tue, 29 Jul 2025 11:33:25 -0400 Subject: [PATCH 8/8] lint --- tftypes/value.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tftypes/value.go b/tftypes/value.go index 6b68ee827..bf9bd1ab7 100644 --- a/tftypes/value.go +++ b/tftypes/value.go @@ -586,7 +586,10 @@ func (val Value) IsFullyNull() bool { return false // already checked IsNull() and not an aggregate case List, Set, Tuple: - sliceVal := val.value.([]Value) + 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 @@ -595,7 +598,10 @@ func (val Value) IsFullyNull() bool { return true case Map, Object: - mapVal := val.value.(map[string]Value) + 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