diff --git a/.changelog/88.txt b/.changelog/88.txt new file mode 100644 index 0000000..9784b25 --- /dev/null +++ b/.changelog/88.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +listvalidator: Added `UniqueValues` validator +``` diff --git a/listvalidator/unique_values.go b/listvalidator/unique_values.go new file mode 100644 index 0000000..b6c6d05 --- /dev/null +++ b/listvalidator/unique_values.go @@ -0,0 +1,65 @@ +package listvalidator + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +var _ validator.List = uniqueValuesValidator{} + +// uniqueValuesValidator implements the validator. +type uniqueValuesValidator struct{} + +// Description returns the plaintext description of the validator. +func (v uniqueValuesValidator) Description(_ context.Context) string { + return "all values must be unique" +} + +// MarkdownDescription returns the Markdown description of the validator. +func (v uniqueValuesValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +// ValidateList implements the validation logic. +func (v uniqueValuesValidator) ValidateList(_ context.Context, req validator.ListRequest, resp *validator.ListResponse) { + if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() { + return + } + + elements := req.ConfigValue.Elements() + + for indexOuter, elementOuter := range elements { + // Only evaluate known values for duplicates. + if elementOuter.IsUnknown() { + continue + } + + for indexInner := indexOuter + 1; indexInner < len(elements); indexInner++ { + elementInner := elements[indexInner] + + if elementInner.IsUnknown() { + continue + } + + if !elementInner.Equal(elementOuter) { + continue + } + + resp.Diagnostics.AddAttributeError( + req.Path, + "Duplicate List Value", + fmt.Sprintf("This attribute contains duplicate values of: %s", elementInner), + ) + } + } +} + +// UniqueValues returns a validator which ensures that any configured list +// only contains unique values. This is similar to using a set attribute type +// which inherently validates unique values, but with list ordering semantics. +// Null (unconfigured) and unknown (known after apply) values are skipped. +func UniqueValues() validator.List { + return uniqueValuesValidator{} +} diff --git a/listvalidator/unique_values_example_test.go b/listvalidator/unique_values_example_test.go new file mode 100644 index 0000000..2274fb7 --- /dev/null +++ b/listvalidator/unique_values_example_test.go @@ -0,0 +1,24 @@ +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/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func ExampleUniqueValues() { + // 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 only unique values. + listvalidator.UniqueValues(), + }, + }, + }, + } +} diff --git a/listvalidator/unique_values_test.go b/listvalidator/unique_values_test.go new file mode 100644 index 0000000..12b6d87 --- /dev/null +++ b/listvalidator/unique_values_test.go @@ -0,0 +1,127 @@ +package listvalidator_test + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func TestUniqueValues(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + list types.List + expectedDiagnostics diag.Diagnostics + }{ + "null-list": { + list: types.ListNull(types.StringType), + expectedDiagnostics: nil, + }, + "unknown-list": { + list: types.ListUnknown(types.StringType), + expectedDiagnostics: nil, + }, + "null-value": { + list: types.ListValueMust( + types.StringType, + []attr.Value{types.StringNull()}, + ), + expectedDiagnostics: nil, + }, + "null-values-duplicate": { + list: types.ListValueMust( + types.StringType, + []attr.Value{types.StringNull(), types.StringNull()}, + ), + expectedDiagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Duplicate List Value", + "This attribute contains duplicate values of: ", + ), + }, + }, + "null-values-valid": { + list: types.ListValueMust( + types.StringType, + []attr.Value{types.StringNull(), types.StringValue("test")}, + ), + expectedDiagnostics: nil, + }, + "unknown-value": { + list: types.ListValueMust( + types.StringType, + []attr.Value{types.StringUnknown()}, + ), + expectedDiagnostics: nil, + }, + "unknown-values-duplicate": { + list: types.ListValueMust( + types.StringType, + []attr.Value{types.StringUnknown(), types.StringUnknown()}, + ), + expectedDiagnostics: nil, + }, + "unknown-values-valid": { + list: types.ListValueMust( + types.StringType, + []attr.Value{types.StringUnknown(), types.StringValue("test")}, + ), + expectedDiagnostics: nil, + }, + "known-value": { + list: types.ListValueMust( + types.StringType, + []attr.Value{types.StringValue("test")}, + ), + expectedDiagnostics: nil, + }, + "known-values-duplicate": { + list: types.ListValueMust( + types.StringType, + []attr.Value{types.StringValue("test"), types.StringValue("test")}, + ), + expectedDiagnostics: diag.Diagnostics{ + diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "Duplicate List Value", + "This attribute contains duplicate values of: \"test\"", + ), + }, + }, + "known-values-valid": { + list: types.ListValueMust( + types.StringType, + []attr.Value{types.StringValue("test1"), types.StringValue("test2")}, + ), + expectedDiagnostics: nil, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + request := validator.ListRequest{ + Path: path.Root("test"), + PathExpression: path.MatchRoot("test"), + ConfigValue: testCase.list, + } + response := validator.ListResponse{} + listvalidator.UniqueValues().ValidateList(context.Background(), request, &response) + + if diff := cmp.Diff(response.Diagnostics, testCase.expectedDiagnostics); diff != "" { + t.Errorf("unexpected diagnostics difference: %s", diff) + } + }) + } +}