Skip to content

Adding AtLeastSumOf, AtMostSumOf, EqualToSumOf Validators #29

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Jul 20, 2022
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions int64validator/at_least_sum_of.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package int64validator

import (
"context"
"fmt"
"strings"

"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/tfsdk"
"github.com/hashicorp/terraform-plugin-framework/types"

"github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag"
)

var _ tfsdk.AttributeValidator = atLeastSumOfValidator{}

// atLeastSumOfValidator validates that an integer Attribute's value is at least the sum of one
// or more integer Attributes retrieved via the given path expressions.
type atLeastSumOfValidator struct {
attributesToSumPathExpressions path.Expressions
}

// Description describes the validation in plain text formatting.
func (validator atLeastSumOfValidator) Description(_ context.Context) string {
var attributePaths []string
for _, p := range validator.attributesToSumPathExpressions {
attributePaths = append(attributePaths, p.String())
}

return fmt.Sprintf("value must be at least sum of %s", strings.Join(attributePaths, " + "))
}

// MarkdownDescription describes the validation in Markdown formatting.
func (validator atLeastSumOfValidator) MarkdownDescription(ctx context.Context) string {
return validator.Description(ctx)
}

// Validate performs the validation.
func (validator atLeastSumOfValidator) Validate(ctx context.Context, request tfsdk.ValidateAttributeRequest, response *tfsdk.ValidateAttributeResponse) {
i, ok := validateInt(ctx, request, response)

if !ok {
return
}

var sumOfAttribs int64
var numUnknownAttribsToSum int

for _, expression := range validator.attributesToSumPathExpressions {
matchedPaths, diags := request.Config.PathMatches(ctx, expression)
response.Diagnostics.Append(diags...)

// Collect all errors
if diags.HasError() {
continue
}

for _, mp := range matchedPaths {
var attribToSum types.Int64

diags := request.Config.GetAttribute(ctx, mp, &attribToSum)
response.Diagnostics.Append(diags...)

// Collect all errors
if diags.HasError() {
continue
}

if attribToSum.IsNull() {
continue
}

if attribToSum.IsUnknown() {
numUnknownAttribsToSum++
continue
}

sumOfAttribs += attribToSum.Value
}
}

if numUnknownAttribsToSum > 0 {
return
}

if i < sumOfAttribs {
response.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic(
request.AttributePath,
validator.Description(ctx),
fmt.Sprintf("%d", i),
))

return
}
}

// AtLeastSumOf returns an AttributeValidator which ensures that any configured
// attribute value:
//
// - Is a number, which can be represented by a 64-bit integer.
// - Is at least the sum of the attributes retrieved via the given path expression(s).
//
// Null (unconfigured) and unknown (known after apply) values are skipped.
func AtLeastSumOf(attributesToSumPathExpressions ...path.Expression) tfsdk.AttributeValidator {
return atLeastSumOfValidator{attributesToSumPathExpressions}
}
181 changes: 181 additions & 0 deletions int64validator/at_least_sum_of_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
package int64validator

import (
"context"
"testing"

"github.com/hashicorp/terraform-plugin-framework/attr"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/tfsdk"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-go/tftypes"
)

func TestAtLeastSumOfValidator(t *testing.T) {
t.Parallel()

type testCase struct {
val attr.Value
attributesToSumExpressions path.Expressions
requestConfigRaw map[string]tftypes.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 less than sum of attributes": {
val: types.Int64{Value: 10},
attributesToSumExpressions: path.Expressions{
path.MatchRoot("one"),
path.MatchRoot("two"),
},
requestConfigRaw: map[string]tftypes.Value{
"one": tftypes.NewValue(tftypes.Number, 15),
"two": tftypes.NewValue(tftypes.Number, 15),
},
Comment on lines +40 to +43
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Drive-by note: It'd be great to ensure there are unit tests covering when the one/two attributes are null and/or unknown as well. Or all three are null/unknown.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have added some additional tests.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These look great! Another good test case might be verifying that giving a path to an unexpected type (e.g. boolean attribute) always returns an error diagnostic. It appears the code should already be doing this, but it might be good to cover this for future code changes. Seems like it should be popping an additional attribute into the test schema, then referencing it in a new test case. 👍

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have added a test case for attributePath(s) referencing unexpected types in at_least|at_most|equal_to_sum_of_test.

expectError: true,
},
"valid integer as Int64 equal to sum of attributes": {
val: types.Int64{Value: 10},
attributesToSumExpressions: path.Expressions{
path.MatchRoot("one"),
path.MatchRoot("two"),
},
requestConfigRaw: map[string]tftypes.Value{
"one": tftypes.NewValue(tftypes.Number, 5),
"two": tftypes.NewValue(tftypes.Number, 5),
},
},
"valid integer as Int64 greater than sum of attributes": {
val: types.Int64{Value: 10},
attributesToSumExpressions: path.Expressions{
path.MatchRoot("one"),
path.MatchRoot("two"),
},
requestConfigRaw: map[string]tftypes.Value{
"one": tftypes.NewValue(tftypes.Number, 4),
"two": tftypes.NewValue(tftypes.Number, 4),
},
},
"valid integer as Int64 greater than sum of attributes, when one summed attribute is null": {
val: types.Int64{Value: 10},
attributesToSumExpressions: path.Expressions{
path.MatchRoot("one"),
path.MatchRoot("two"),
},
requestConfigRaw: map[string]tftypes.Value{
"one": tftypes.NewValue(tftypes.Number, nil),
"two": tftypes.NewValue(tftypes.Number, 9),
},
},
"valid integer as Int64 does not return error when all attributes are null": {
val: types.Int64{Null: true},
attributesToSumExpressions: path.Expressions{
path.MatchRoot("one"),
path.MatchRoot("two"),
},
requestConfigRaw: map[string]tftypes.Value{
"one": tftypes.NewValue(tftypes.Number, nil),
"two": tftypes.NewValue(tftypes.Number, nil),
},
},
"valid integer as Int64 returns error when all attributes to sum are null": {
val: types.Int64{Value: -1},
attributesToSumExpressions: path.Expressions{
path.MatchRoot("one"),
path.MatchRoot("two"),
},
requestConfigRaw: map[string]tftypes.Value{
"one": tftypes.NewValue(tftypes.Number, nil),
"two": tftypes.NewValue(tftypes.Number, nil),
},
expectError: true,
},
"valid integer as Int64 greater than sum of attributes, when one summed attribute is unknown": {
val: types.Int64{Value: 10},
attributesToSumExpressions: path.Expressions{
path.MatchRoot("one"),
path.MatchRoot("two"),
},
requestConfigRaw: map[string]tftypes.Value{
"one": tftypes.NewValue(tftypes.Number, tftypes.UnknownValue),
"two": tftypes.NewValue(tftypes.Number, 9),
},
},
"valid integer as Int64 does not return error when all attributes are unknown": {
val: types.Int64{Unknown: true},
attributesToSumExpressions: path.Expressions{
path.MatchRoot("one"),
path.MatchRoot("two"),
},
requestConfigRaw: map[string]tftypes.Value{
"one": tftypes.NewValue(tftypes.Number, tftypes.UnknownValue),
"two": tftypes.NewValue(tftypes.Number, tftypes.UnknownValue),
},
},
"valid integer as Int64 does not return error when all attributes to sum are unknown": {
val: types.Int64{Value: -1},
attributesToSumExpressions: path.Expressions{
path.MatchRoot("one"),
path.MatchRoot("two"),
},
requestConfigRaw: map[string]tftypes.Value{
"one": tftypes.NewValue(tftypes.Number, tftypes.UnknownValue),
"two": tftypes.NewValue(tftypes.Number, tftypes.UnknownValue),
},
},
"error when attribute to sum is not Number": {
val: types.Int64{Value: 9},
attributesToSumExpressions: path.Expressions{
path.MatchRoot("one"),
path.MatchRoot("two"),
},
requestConfigRaw: map[string]tftypes.Value{
"one": tftypes.NewValue(tftypes.Bool, true),
"two": tftypes.NewValue(tftypes.Number, 9),
},
expectError: true,
},
}

for name, test := range tests {
name, test := name, test
t.Run(name, func(t *testing.T) {
request := tfsdk.ValidateAttributeRequest{
AttributePath: path.Root("test"),
AttributePathExpression: path.MatchRoot("test"),
AttributeConfig: test.val,
Config: tfsdk.Config{
Raw: tftypes.NewValue(tftypes.Object{}, test.requestConfigRaw),
Schema: tfsdk.Schema{
Attributes: map[string]tfsdk.Attribute{
"test": {Type: types.Int64Type},
"one": {Type: types.Int64Type},
"two": {Type: types.Int64Type},
},
},
},
}

response := tfsdk.ValidateAttributeResponse{}

AtLeastSumOf(test.attributesToSumExpressions...).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)
}
})
}
}
106 changes: 106 additions & 0 deletions int64validator/at_most_sum_of.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package int64validator

import (
"context"
"fmt"
"strings"

"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/tfsdk"
"github.com/hashicorp/terraform-plugin-framework/types"

"github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag"
)

var _ tfsdk.AttributeValidator = atMostSumOfValidator{}

// atMostSumOfValidator validates that an integer Attribute's value is at most the sum of one
// or more integer Attributes retrieved via the given path expressions.
type atMostSumOfValidator struct {
attributesToSumPathExpressions path.Expressions
}

// Description describes the validation in plain text formatting.
func (validator atMostSumOfValidator) Description(_ context.Context) string {
var attributePaths []string
for _, p := range validator.attributesToSumPathExpressions {
attributePaths = append(attributePaths, p.String())
}

return fmt.Sprintf("value must be at most sum of %s", strings.Join(attributePaths, " + "))
}

// MarkdownDescription describes the validation in Markdown formatting.
func (validator atMostSumOfValidator) MarkdownDescription(ctx context.Context) string {
return validator.Description(ctx)
}

// Validate performs the validation.
func (validator atMostSumOfValidator) Validate(ctx context.Context, request tfsdk.ValidateAttributeRequest, response *tfsdk.ValidateAttributeResponse) {
i, ok := validateInt(ctx, request, response)

if !ok {
return
}

var sumOfAttribs int64
var numUnknownAttribsToSum int

for _, expression := range validator.attributesToSumPathExpressions {
matchedPaths, diags := request.Config.PathMatches(ctx, expression)
response.Diagnostics.Append(diags...)

// Collect all errors
if diags.HasError() {
continue
}

for _, mp := range matchedPaths {
var attribToSum types.Int64

diags := request.Config.GetAttribute(ctx, mp, &attribToSum)
response.Diagnostics.Append(diags...)

// Collect all errors
if diags.HasError() {
continue
}

if attribToSum.IsNull() {
continue
}

if attribToSum.IsUnknown() {
numUnknownAttribsToSum++
continue
}

sumOfAttribs += attribToSum.Value
}
}

if numUnknownAttribsToSum > 0 {
return
}

if i > sumOfAttribs {
response.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic(
request.AttributePath,
validator.Description(ctx),
fmt.Sprintf("%d", i),
))

return
}
}

// AtMostSumOf returns an AttributeValidator which ensures that any configured
// attribute value:
//
// - Is a number, which can be represented by a 64-bit integer.
// - Is at most the sum of the given attributes retrieved via the given path expression(s).
//
// Null (unconfigured) and unknown (known after apply) values are skipped.
func AtMostSumOf(attributesToSumPathExpressions ...path.Expression) tfsdk.AttributeValidator {
return atMostSumOfValidator{attributesToSumPathExpressions}
}
Loading