diff --git a/.changes/unreleased/FEATURES-20241212-140548.yaml b/.changes/unreleased/FEATURES-20241212-140548.yaml new file mode 100644 index 00000000..5a41a0d8 --- /dev/null +++ b/.changes/unreleased/FEATURES-20241212-140548.yaml @@ -0,0 +1,5 @@ +kind: FEATURES +body: 'listvalidator: Added `NoNullValues` validator' +time: 2024-12-12T14:05:48.064529-05:00 +custom: + Issue: "245" diff --git a/.changes/unreleased/FEATURES-20241212-140613.yaml b/.changes/unreleased/FEATURES-20241212-140613.yaml new file mode 100644 index 00000000..91a69a9a --- /dev/null +++ b/.changes/unreleased/FEATURES-20241212-140613.yaml @@ -0,0 +1,5 @@ +kind: FEATURES +body: 'mapvalidator: Added `NoNullValues` validator' +time: 2024-12-12T14:06:13.229216-05:00 +custom: + Issue: "245" diff --git a/.changes/unreleased/FEATURES-20241212-140624.yaml b/.changes/unreleased/FEATURES-20241212-140624.yaml new file mode 100644 index 00000000..92fb1f62 --- /dev/null +++ b/.changes/unreleased/FEATURES-20241212-140624.yaml @@ -0,0 +1,5 @@ +kind: FEATURES +body: 'setvalidator: Added `NoNullValues` validator' +time: 2024-12-12T14:06:24.967598-05:00 +custom: + Issue: "245" diff --git a/listvalidator/no_null_values.go b/listvalidator/no_null_values.go new file mode 100644 index 00000000..a66e8b0b --- /dev/null +++ b/listvalidator/no_null_values.go @@ -0,0 +1,78 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package listvalidator + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/function" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +var _ validator.List = noNullValuesValidator{} +var _ function.ListParameterValidator = noNullValuesValidator{} + +type noNullValuesValidator struct{} + +func (v noNullValuesValidator) Description(_ context.Context) string { + return "All values in the list must be configured" +} + +func (v noNullValuesValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +func (v noNullValuesValidator) ValidateList(_ context.Context, req validator.ListRequest, resp *validator.ListResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + elements := req.ConfigValue.Elements() + + for _, e := range elements { + // Only evaluate known values for null + if e.IsUnknown() { + continue + } + + if e.IsNull() { + resp.Diagnostics.AddAttributeError( + req.Path, + "Null List Value", + "This attribute contains a null value.", + ) + } + } +} + +func (v noNullValuesValidator) ValidateParameterList(ctx context.Context, req function.ListParameterValidatorRequest, resp *function.ListParameterValidatorResponse) { + if req.Value.IsNull() || req.Value.IsUnknown() { + return + } + + elements := req.Value.Elements() + + for _, e := range elements { + // Only evaluate known values for null + if e.IsUnknown() { + continue + } + + if e.IsNull() { + resp.Error = function.ConcatFuncErrors( + resp.Error, + function.NewArgumentFuncError( + req.ArgumentPosition, + "Null List Value: This attribute contains a null value.", + ), + ) + } + } +} + +// NoNullValues returns a validator which ensures that any configured list +// only contains non-null values. +func NoNullValues() noNullValuesValidator { + return noNullValuesValidator{} +} diff --git a/listvalidator/no_null_values_example_test.go b/listvalidator/no_null_values_example_test.go new file mode 100644 index 00000000..c141daec --- /dev/null +++ b/listvalidator/no_null_values_example_test.go @@ -0,0 +1,42 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package listvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/function" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func ExampleNoNullValues() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.ListAttribute{ + ElementType: types.StringType, + Required: true, + Validators: []validator.List{ + // Validate this list must contain no null values. + listvalidator.NoNullValues(), + }, + }, + }, + } +} + +func ExampleNoNullValues_function() { + _ = function.Definition{ + Parameters: []function.Parameter{ + function.ListParameter{ + Name: "example_param", + Validators: []function.ListParameterValidator{ + // Validate this list must contain no null values. + listvalidator.NoNullValues(), + }, + }, + }, + } +} diff --git a/listvalidator/no_null_values_test.go b/listvalidator/no_null_values_test.go new file mode 100644 index 00000000..f2ae0280 --- /dev/null +++ b/listvalidator/no_null_values_test.go @@ -0,0 +1,109 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package listvalidator_test + +import ( + "context" + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/function" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func TestNoNullValuesValidator(t *testing.T) { + t.Parallel() + + type testCase struct { + val types.List + expectError bool + } + tests := map[string]testCase{ + "List unknown": { + val: types.ListUnknown( + types.StringType, + ), + expectError: false, + }, + "List null": { + val: types.ListNull( + types.StringType, + ), + expectError: false, + }, + "No null values": { + val: types.ListValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringValue("second"), + }, + ), + expectError: false, + }, + "Unknown value": { + val: types.ListValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringUnknown(), + }, + ), + expectError: false, + }, + "Null value": { + val: types.ListValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringNull(), + }, + ), + expectError: true, + }, + } + + for name, test := range tests { + t.Run(fmt.Sprintf("ValidateList - %s", name), func(t *testing.T) { + t.Parallel() + request := validator.ListRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, + } + response := validator.ListResponse{} + listvalidator.NoNullValues().ValidateList(context.TODO(), request, &response) + + if !response.Diagnostics.HasError() && test.expectError { + t.Fatal("expected error, got no error") + } + + if response.Diagnostics.HasError() && !test.expectError { + t.Fatalf("got unexpected error: %s", response.Diagnostics) + } + }) + + t.Run(fmt.Sprintf("ValidateParameterList - %s", name), func(t *testing.T) { + t.Parallel() + request := function.ListParameterValidatorRequest{ + ArgumentPosition: 0, + Value: test.val, + } + response := function.ListParameterValidatorResponse{} + listvalidator.NoNullValues().ValidateParameterList(context.TODO(), request, &response) + + if response.Error == nil && test.expectError { + t.Fatal("expected error, got no error") + } + + if response.Error != nil && !test.expectError { + t.Fatalf("got unexpected error: %s", response.Error) + } + }) + } +} diff --git a/mapvalidator/no_null_values.go b/mapvalidator/no_null_values.go new file mode 100644 index 00000000..0c9eb69d --- /dev/null +++ b/mapvalidator/no_null_values.go @@ -0,0 +1,78 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package mapvalidator + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/function" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +var _ validator.Map = noNullValuesValidator{} +var _ function.MapParameterValidator = noNullValuesValidator{} + +type noNullValuesValidator struct{} + +func (v noNullValuesValidator) Description(_ context.Context) string { + return "All values in the map must be configured" +} + +func (v noNullValuesValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +func (v noNullValuesValidator) ValidateMap(_ context.Context, req validator.MapRequest, resp *validator.MapResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + elements := req.ConfigValue.Elements() + + for _, e := range elements { + // Only evaluate known values for null + if e.IsUnknown() { + continue + } + + if e.IsNull() { + resp.Diagnostics.AddAttributeError( + req.Path, + "Null Map Value", + "This attribute contains a null value.", + ) + } + } +} + +func (v noNullValuesValidator) ValidateParameterMap(ctx context.Context, req function.MapParameterValidatorRequest, resp *function.MapParameterValidatorResponse) { + if req.Value.IsNull() || req.Value.IsUnknown() { + return + } + + elements := req.Value.Elements() + + for _, e := range elements { + // Only evaluate known values for null + if e.IsUnknown() { + continue + } + + if e.IsNull() { + resp.Error = function.ConcatFuncErrors( + resp.Error, + function.NewArgumentFuncError( + req.ArgumentPosition, + "Null Map Value: This attribute contains a null value.", + ), + ) + } + } +} + +// NoNullValues returns a validator which ensures that any configured map +// only contains non-null values. +func NoNullValues() noNullValuesValidator { + return noNullValuesValidator{} +} diff --git a/mapvalidator/no_null_values_example_test.go b/mapvalidator/no_null_values_example_test.go new file mode 100644 index 00000000..3a1604d4 --- /dev/null +++ b/mapvalidator/no_null_values_example_test.go @@ -0,0 +1,42 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package mapvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/function" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func ExampleNoNullValues() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.MapAttribute{ + ElementType: types.StringType, + Required: true, + Validators: []validator.Map{ + // Validate this map must contain no null values. + mapvalidator.NoNullValues(), + }, + }, + }, + } +} + +func ExampleNoNullValues_function() { + _ = function.Definition{ + Parameters: []function.Parameter{ + function.MapParameter{ + Name: "example_param", + Validators: []function.MapParameterValidator{ + // Validate this map must contain no null values. + mapvalidator.NoNullValues(), + }, + }, + }, + } +} diff --git a/mapvalidator/no_null_values_test.go b/mapvalidator/no_null_values_test.go new file mode 100644 index 00000000..18e05e9d --- /dev/null +++ b/mapvalidator/no_null_values_test.go @@ -0,0 +1,109 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package mapvalidator_test + +import ( + "context" + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/function" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func TestNoNullValuesValidator(t *testing.T) { + t.Parallel() + + type testCase struct { + val types.Map + expectError bool + } + tests := map[string]testCase{ + "Map unknown": { + val: types.MapUnknown( + types.StringType, + ), + expectError: false, + }, + "Map null": { + val: types.MapNull( + types.StringType, + ), + expectError: false, + }, + "No null values": { + val: types.MapValueMust( + types.StringType, + map[string]attr.Value{ + "key1": types.StringValue("first"), + "key2": types.StringValue("second"), + }, + ), + expectError: false, + }, + "Unknown value": { + val: types.MapValueMust( + types.StringType, + map[string]attr.Value{ + "key1": types.StringValue("first"), + "key2": types.StringUnknown(), + }, + ), + expectError: false, + }, + "Null value": { + val: types.MapValueMust( + types.StringType, + map[string]attr.Value{ + "key1": types.StringValue("first"), + "key2": types.StringNull(), + }, + ), + expectError: true, + }, + } + + for name, test := range tests { + t.Run(fmt.Sprintf("ValidateMap - %s", name), func(t *testing.T) { + t.Parallel() + request := validator.MapRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, + } + response := validator.MapResponse{} + mapvalidator.NoNullValues().ValidateMap(context.TODO(), request, &response) + + if !response.Diagnostics.HasError() && test.expectError { + t.Fatal("expected error, got no error") + } + + if response.Diagnostics.HasError() && !test.expectError { + t.Fatalf("got unexpected error: %s", response.Diagnostics) + } + }) + + t.Run(fmt.Sprintf("ValidateParameterMap - %s", name), func(t *testing.T) { + t.Parallel() + request := function.MapParameterValidatorRequest{ + ArgumentPosition: 0, + Value: test.val, + } + response := function.MapParameterValidatorResponse{} + mapvalidator.NoNullValues().ValidateParameterMap(context.TODO(), request, &response) + + if response.Error == nil && test.expectError { + t.Fatal("expected error, got no error") + } + + if response.Error != nil && !test.expectError { + t.Fatalf("got unexpected error: %s", response.Error) + } + }) + } +} diff --git a/setvalidator/no_null_values.go b/setvalidator/no_null_values.go new file mode 100644 index 00000000..a4ae414c --- /dev/null +++ b/setvalidator/no_null_values.go @@ -0,0 +1,78 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package setvalidator + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/function" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +var _ validator.Set = noNullValuesValidator{} +var _ function.SetParameterValidator = noNullValuesValidator{} + +type noNullValuesValidator struct{} + +func (v noNullValuesValidator) Description(_ context.Context) string { + return "All values in the set must be configured" +} + +func (v noNullValuesValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +func (v noNullValuesValidator) ValidateSet(_ context.Context, req validator.SetRequest, resp *validator.SetResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + elements := req.ConfigValue.Elements() + + for _, e := range elements { + // Only evaluate known values for null + if e.IsUnknown() { + continue + } + + if e.IsNull() { + resp.Diagnostics.AddAttributeError( + req.Path, + "Null Set Value", + "This attribute contains a null value.", + ) + } + } +} + +func (v noNullValuesValidator) ValidateParameterSet(ctx context.Context, req function.SetParameterValidatorRequest, resp *function.SetParameterValidatorResponse) { + if req.Value.IsNull() || req.Value.IsUnknown() { + return + } + + elements := req.Value.Elements() + + for _, e := range elements { + // Only evaluate known values for null + if e.IsUnknown() { + continue + } + + if e.IsNull() { + resp.Error = function.ConcatFuncErrors( + resp.Error, + function.NewArgumentFuncError( + req.ArgumentPosition, + "Null Set Value: This attribute contains a null value.", + ), + ) + } + } +} + +// NoNullValues returns a validator which ensures that any configured set +// only contains non-null values. +func NoNullValues() noNullValuesValidator { + return noNullValuesValidator{} +} diff --git a/setvalidator/no_null_values_example_test.go b/setvalidator/no_null_values_example_test.go new file mode 100644 index 00000000..2e1de727 --- /dev/null +++ b/setvalidator/no_null_values_example_test.go @@ -0,0 +1,42 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package setvalidator_test + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/function" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func ExampleNoNullValues() { + // Used within a Schema method of a DataSource, Provider, or Resource + _ = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "example_attr": schema.SetAttribute{ + ElementType: types.StringType, + Required: true, + Validators: []validator.Set{ + // Validate this set must contain no null values. + setvalidator.NoNullValues(), + }, + }, + }, + } +} + +func ExampleNoNullValues_function() { + _ = function.Definition{ + Parameters: []function.Parameter{ + function.SetParameter{ + Name: "example_param", + Validators: []function.SetParameterValidator{ + // Validate this set must contain no null values. + setvalidator.NoNullValues(), + }, + }, + }, + } +} diff --git a/setvalidator/no_null_values_test.go b/setvalidator/no_null_values_test.go new file mode 100644 index 00000000..7ae47fe3 --- /dev/null +++ b/setvalidator/no_null_values_test.go @@ -0,0 +1,109 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package setvalidator_test + +import ( + "context" + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/function" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func TestNoNullValuesValidator(t *testing.T) { + t.Parallel() + + type testCase struct { + val types.Set + expectError bool + } + tests := map[string]testCase{ + "Set unknown": { + val: types.SetUnknown( + types.StringType, + ), + expectError: false, + }, + "Set null": { + val: types.SetNull( + types.StringType, + ), + expectError: false, + }, + "No null values": { + val: types.SetValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringValue("second"), + }, + ), + expectError: false, + }, + "Unknown value": { + val: types.SetValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringUnknown(), + }, + ), + expectError: false, + }, + "Null value": { + val: types.SetValueMust( + types.StringType, + []attr.Value{ + types.StringValue("first"), + types.StringNull(), + }, + ), + expectError: true, + }, + } + + for name, test := range tests { + t.Run(fmt.Sprintf("ValidateSet - %s", name), func(t *testing.T) { + t.Parallel() + request := validator.SetRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: test.val, + } + response := validator.SetResponse{} + setvalidator.NoNullValues().ValidateSet(context.TODO(), request, &response) + + if !response.Diagnostics.HasError() && test.expectError { + t.Fatal("expected error, got no error") + } + + if response.Diagnostics.HasError() && !test.expectError { + t.Fatalf("got unexpected error: %s", response.Diagnostics) + } + }) + + t.Run(fmt.Sprintf("ValidateParameterSet - %s", name), func(t *testing.T) { + t.Parallel() + request := function.SetParameterValidatorRequest{ + ArgumentPosition: 0, + Value: test.val, + } + response := function.SetParameterValidatorResponse{} + setvalidator.NoNullValues().ValidateParameterSet(context.TODO(), request, &response) + + if response.Error == nil && test.expectError { + t.Fatal("expected error, got no error") + } + + if response.Error != nil && !test.expectError { + t.Fatalf("got unexpected error: %s", response.Error) + } + }) + } +}