diff --git a/int64validator/equal_to_sum_of.go b/int64validator/equal_to_sum_of.go new file mode 100644 index 0000000..e85d26f --- /dev/null +++ b/int64validator/equal_to_sum_of.go @@ -0,0 +1,82 @@ +package int64validator + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" + + "github.com/hashicorp/terraform-plugin-framework-validators/validatordiag" +) + +var _ tfsdk.AttributeValidator = equalToSumOfValidator{} + +// equalToSumOfValidator validates that an integer Attribute's value equals the sum of one +// or more integer Attributes. +type equalToSumOfValidator struct { + attributesToSumPaths []*tftypes.AttributePath +} + +// Description describes the validation in plain text formatting. +func (validator equalToSumOfValidator) Description(_ context.Context) string { + var attributePaths []string + for _, path := range validator.attributesToSumPaths { + attributePaths = append(attributePaths, path.String()) + } + + return fmt.Sprintf("value must be equal to the sum of %s", strings.Join(attributePaths, " + ")) +} + +// MarkdownDescription describes the validation in Markdown formatting. +func (validator equalToSumOfValidator) MarkdownDescription(ctx context.Context) string { + return validator.Description(ctx) +} + +// Validate performs the validation. +func (validator equalToSumOfValidator) Validate(ctx context.Context, request tfsdk.ValidateAttributeRequest, response *tfsdk.ValidateAttributeResponse) { + i, ok := validateInt(ctx, request, response) + + if !ok { + return + } + + var sumOfAttribs int64 + + for _, path := range validator.attributesToSumPaths { + var attribToSum types.Int64 + + response.Diagnostics.Append(request.Config.GetAttribute(ctx, path, &attribToSum)...) + if response.Diagnostics.HasError() { + return + } + + sumOfAttribs += attribToSum.Value + } + + if i != sumOfAttribs { + + response.Diagnostics.Append(validatordiag.AttributeValueDiagnostic( + request.AttributePath, + validator.Description(ctx), + fmt.Sprintf("%d", i), + )) + + return + } +} + +// EqualToSumOf returns an AttributeValidator which ensures that any configured +// attribute value: +// +// - Is a number, which can be represented by a 64-bit integer. +// - Is exclusively equal to the sum of the given attributes. +// +// Null (unconfigured) and unknown (known after apply) values are skipped. +func EqualToSumOf(attributesToSum []*tftypes.AttributePath) tfsdk.AttributeValidator { + return equalToSumOfValidator{ + attributesToSumPaths: attributesToSum, + } +} diff --git a/int64validator/equal_to_sum_of_test.go b/int64validator/equal_to_sum_of_test.go new file mode 100644 index 0000000..de2c236 --- /dev/null +++ b/int64validator/equal_to_sum_of_test.go @@ -0,0 +1,156 @@ +package int64validator + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/tfsdk" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +func TestEqualToSumOfValidator(t *testing.T) { + t.Parallel() + + type testCase struct { + val attr.Value + attributesToSumPaths []*tftypes.AttributePath + requestConfigRaw map[string]attr.Value + expectError bool + } + tests := map[string]testCase{ + "not an Int64": { + val: types.Bool{Value: true}, + expectError: true, + }, + "unknown Int64": { + val: types.Int64{Unknown: true}, + }, + "null Int64": { + val: types.Int64{Null: true}, + }, + "valid integer as Int64 more than sum of attributes": { + val: types.Int64{Value: 11}, + attributesToSumPaths: []*tftypes.AttributePath{ + tftypes.NewAttributePath().WithAttributeName("one"), + tftypes.NewAttributePath().WithAttributeName("two"), + }, + requestConfigRaw: map[string]attr.Value{ + "one": types.Int64{Value: 5}, + "two": types.Int64{Value: 5}, + }, + expectError: true, + }, + "valid integer as Int64 less than sum of attributes": { + val: types.Int64{Value: 9}, + attributesToSumPaths: []*tftypes.AttributePath{ + tftypes.NewAttributePath().WithAttributeName("one"), + tftypes.NewAttributePath().WithAttributeName("two"), + }, + requestConfigRaw: map[string]attr.Value{ + "one": types.Int64{Value: 5}, + "two": types.Int64{Value: 5}, + }, + expectError: true, + }, + "valid integer as Int64 equal to sum of attributes": { + val: types.Int64{Value: 10}, + attributesToSumPaths: []*tftypes.AttributePath{ + tftypes.NewAttributePath().WithAttributeName("one"), + tftypes.NewAttributePath().WithAttributeName("two"), + }, + requestConfigRaw: map[string]attr.Value{ + "one": types.Int64{Value: 5}, + "two": types.Int64{Value: 5}, + }, + }, + "valid integer as Int64 equal to sum of attributes, when one summed attribute is null": { + val: types.Int64{Value: 8}, + attributesToSumPaths: []*tftypes.AttributePath{ + tftypes.NewAttributePath().WithAttributeName("one"), + tftypes.NewAttributePath().WithAttributeName("two"), + }, + requestConfigRaw: map[string]attr.Value{ + "one": types.Int64{Null: true}, + "two": types.Int64{Value: 8}, + }, + }, + "valid integer as Int64 does not return error when all attributes are null": { + val: types.Int64{Null: true}, + attributesToSumPaths: []*tftypes.AttributePath{ + tftypes.NewAttributePath().WithAttributeName("one"), + tftypes.NewAttributePath().WithAttributeName("two"), + }, + requestConfigRaw: map[string]attr.Value{ + "one": types.Int64{Null: true}, + "two": types.Int64{Null: true}, + }, + }, + "valid integer as Int64 equal to sum of attributes, when one summed attribute is unknown": { + val: types.Int64{Value: 8}, + attributesToSumPaths: []*tftypes.AttributePath{ + tftypes.NewAttributePath().WithAttributeName("one"), + tftypes.NewAttributePath().WithAttributeName("two"), + }, + requestConfigRaw: map[string]attr.Value{ + "one": types.Int64{Unknown: true}, + "two": types.Int64{Value: 8}, + }, + }, + "valid integer as Int64 does not return error when all attributes are unknown": { + val: types.Int64{Unknown: true}, + attributesToSumPaths: []*tftypes.AttributePath{ + tftypes.NewAttributePath().WithAttributeName("one"), + tftypes.NewAttributePath().WithAttributeName("two"), + }, + requestConfigRaw: map[string]attr.Value{ + "one": types.Int64{Unknown: true}, + "two": types.Int64{Unknown: true}, + }, + }, + } + + for name, test := range tests { + name, test := name, test + t.Run(name, func(t *testing.T) { + reqConf := make(map[string]tftypes.Value, len(test.requestConfigRaw)) + + for k, v := range test.requestConfigRaw { + val, err := v.ToTerraformValue(context.Background()) + if err != nil { + t.Fatalf("could not attr.Value at key:%s to tftypes.Value", k) + } + + reqConf[k] = val + } + + request := tfsdk.ValidateAttributeRequest{ + AttributePath: tftypes.NewAttributePath().WithAttributeName("test"), + AttributeConfig: test.val, + Config: tfsdk.Config{ + Raw: tftypes.NewValue(tftypes.Object{}, reqConf), + Schema: tfsdk.Schema{ + Attributes: map[string]tfsdk.Attribute{ + "test": {Type: types.Int64Type}, + "one": {Type: types.Int64Type}, + "two": {Type: types.Int64Type}, + }, + }, + }, + } + + response := tfsdk.ValidateAttributeResponse{} + + EqualToSumOf(test.attributesToSumPaths).Validate(context.Background(), 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) + } + }) + } +}