diff --git a/.changes/unreleased/ENHANCEMENTS-20230627-125806.yaml b/.changes/unreleased/ENHANCEMENTS-20230627-125806.yaml new file mode 100644 index 000000000..d345d75e4 --- /dev/null +++ b/.changes/unreleased/ENHANCEMENTS-20230627-125806.yaml @@ -0,0 +1,6 @@ +kind: ENHANCEMENTS +body: 'tfprotov5: Added `DynamicValue` type `IsNull` method, which enables checking + if the value is null without type information and fully decoding underlying data' +time: 2023-06-27T12:58:06.917152-04:00 +custom: + Issue: "305" diff --git a/.changes/unreleased/ENHANCEMENTS-20230627-125912.yaml b/.changes/unreleased/ENHANCEMENTS-20230627-125912.yaml new file mode 100644 index 000000000..5e73e3dcf --- /dev/null +++ b/.changes/unreleased/ENHANCEMENTS-20230627-125912.yaml @@ -0,0 +1,6 @@ +kind: ENHANCEMENTS +body: 'tfprotov6: Added `DynamicValue` type `IsNull` method, which enables checking + if the value is null without type information and fully decoding underlying data' +time: 2023-06-27T12:59:12.941648-04:00 +custom: + Issue: "305" diff --git a/tfprotov5/dynamic_value.go b/tfprotov5/dynamic_value.go index a190d3cb2..d21e49661 100644 --- a/tfprotov5/dynamic_value.go +++ b/tfprotov5/dynamic_value.go @@ -4,9 +4,14 @@ package tfprotov5 import ( + "bytes" + "encoding/json" "errors" + "fmt" "github.com/hashicorp/terraform-plugin-go/tftypes" + msgpack "github.com/vmihailenco/msgpack/v5" + "github.com/vmihailenco/msgpack/v5/msgpcode" ) // ErrUnknownDynamicValueType is returned when a DynamicValue has no MsgPack or @@ -47,6 +52,43 @@ type DynamicValue struct { JSON []byte } +// IsNull returns true if the DynamicValue represents a null value based on the +// underlying JSON or MessagePack data. +func (d DynamicValue) IsNull() (bool, error) { + if d.JSON != nil { + decoder := json.NewDecoder(bytes.NewReader(d.JSON)) + token, err := decoder.Token() + + if err != nil { + return false, fmt.Errorf("unable to read DynamicValue JSON token: %w", err) + } + + if token != nil { + return false, nil + } + + return true, nil + } + + if d.MsgPack != nil { + decoder := msgpack.NewDecoder(bytes.NewReader(d.MsgPack)) + code, err := decoder.PeekCode() + + if err != nil { + return false, fmt.Errorf("unable to read DynamicValue MsgPack code: %w", err) + } + + // Extensions are considered unknown + if msgpcode.IsExt(code) || code != msgpcode.Nil { + return false, nil + } + + return true, nil + } + + return false, fmt.Errorf("unable to read DynamicValue: %w", ErrUnknownDynamicValueType) +} + // Unmarshal returns a `tftypes.Value` that represents the information // contained in the DynamicValue in an easy-to-interact-with way. It is the // main purpose of the DynamicValue type, and is how provider developers should diff --git a/tfprotov5/dynamic_value_test.go b/tfprotov5/dynamic_value_test.go new file mode 100644 index 000000000..36a0ccc45 --- /dev/null +++ b/tfprotov5/dynamic_value_test.go @@ -0,0 +1,107 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package tfprotov5_test + +import ( + "fmt" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-go/tfprotov5" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestDynamicValueIsNull(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + dynamicValue tfprotov5.DynamicValue + expected bool + expectedError error + }{ + "empty-dynamic-value": { + dynamicValue: tfprotov5.DynamicValue{}, + expected: false, + expectedError: fmt.Errorf("unable to read DynamicValue: DynamicValue had no JSON or msgpack data set"), + }, + "null": { + dynamicValue: testNewDynamicValueMust(t, + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + nil, + ), + ), + expected: true, + }, + "known": { + dynamicValue: testNewDynamicValueMust(t, + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test_string_attribute": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + ), + expected: false, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, err := testCase.dynamicValue.IsNull() + + if err != nil { + if testCase.expectedError == nil { + t.Fatalf("wanted no error, got error: %s", err) + } + + if !strings.Contains(err.Error(), testCase.expectedError.Error()) { + t.Fatalf("wanted error %q, got error: %s", testCase.expectedError.Error(), err.Error()) + } + } + + if err == nil && testCase.expectedError != nil { + t.Fatalf("got no error, wanted err: %s", testCase.expectedError) + } + + if got != testCase.expected { + t.Errorf("expected %t, got %t", testCase.expected, got) + } + }) + } +} + +func testNewDynamicValueMust(t *testing.T, typ tftypes.Type, value tftypes.Value) tfprotov5.DynamicValue { + t.Helper() + + dynamicValue, err := tfprotov5.NewDynamicValue(typ, value) + + if err != nil { + t.Fatalf("unable to create DynamicValue: %s", err) + } + + return dynamicValue +} diff --git a/tfprotov6/dynamic_value.go b/tfprotov6/dynamic_value.go index deb18a866..76bac4d5d 100644 --- a/tfprotov6/dynamic_value.go +++ b/tfprotov6/dynamic_value.go @@ -4,9 +4,14 @@ package tfprotov6 import ( + "bytes" + "encoding/json" "errors" + "fmt" "github.com/hashicorp/terraform-plugin-go/tftypes" + msgpack "github.com/vmihailenco/msgpack/v5" + "github.com/vmihailenco/msgpack/v5/msgpcode" ) // ErrUnknownDynamicValueType is returned when a DynamicValue has no MsgPack or @@ -47,6 +52,43 @@ type DynamicValue struct { JSON []byte } +// IsNull returns true if the DynamicValue represents a null value based on the +// underlying JSON or MessagePack data. +func (d DynamicValue) IsNull() (bool, error) { + if d.JSON != nil { + decoder := json.NewDecoder(bytes.NewReader(d.JSON)) + token, err := decoder.Token() + + if err != nil { + return false, fmt.Errorf("unable to read DynamicValue JSON token: %w", err) + } + + if token != nil { + return false, nil + } + + return true, nil + } + + if d.MsgPack != nil { + decoder := msgpack.NewDecoder(bytes.NewReader(d.MsgPack)) + code, err := decoder.PeekCode() + + if err != nil { + return false, fmt.Errorf("unable to read DynamicValue MsgPack code: %w", err) + } + + // Extensions are considered unknown + if msgpcode.IsExt(code) || code != msgpcode.Nil { + return false, nil + } + + return true, nil + } + + return false, fmt.Errorf("unable to read DynamicValue: %w", ErrUnknownDynamicValueType) +} + // Unmarshal returns a `tftypes.Value` that represents the information // contained in the DynamicValue in an easy-to-interact-with way. It is the // main purpose of the DynamicValue type, and is how provider developers should diff --git a/tfprotov6/dynamic_value_test.go b/tfprotov6/dynamic_value_test.go new file mode 100644 index 000000000..3e2a4e85d --- /dev/null +++ b/tfprotov6/dynamic_value_test.go @@ -0,0 +1,107 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package tfprotov6_test + +import ( + "fmt" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestDynamicValueIsNull(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + dynamicValue tfprotov6.DynamicValue + expected bool + expectedError error + }{ + "empty-dynamic-value": { + dynamicValue: tfprotov6.DynamicValue{}, + expected: false, + expectedError: fmt.Errorf("unable to read DynamicValue: DynamicValue had no JSON or msgpack data set"), + }, + "null": { + dynamicValue: testNewDynamicValueMust(t, + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + nil, + ), + ), + expected: true, + }, + "known": { + dynamicValue: testNewDynamicValueMust(t, + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "test_string_attribute": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "test_string_attribute": tftypes.NewValue(tftypes.String, "test-value"), + }, + ), + ), + expected: false, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, err := testCase.dynamicValue.IsNull() + + if err != nil { + if testCase.expectedError == nil { + t.Fatalf("wanted no error, got error: %s", err) + } + + if !strings.Contains(err.Error(), testCase.expectedError.Error()) { + t.Fatalf("wanted error %q, got error: %s", testCase.expectedError.Error(), err.Error()) + } + } + + if err == nil && testCase.expectedError != nil { + t.Fatalf("got no error, wanted err: %s", testCase.expectedError) + } + + if got != testCase.expected { + t.Errorf("expected %t, got %t", testCase.expected, got) + } + }) + } +} + +func testNewDynamicValueMust(t *testing.T, typ tftypes.Type, value tftypes.Value) tfprotov6.DynamicValue { + t.Helper() + + dynamicValue, err := tfprotov6.NewDynamicValue(typ, value) + + if err != nil { + t.Fatalf("unable to create DynamicValue: %s", err) + } + + return dynamicValue +}