Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
175 changes: 175 additions & 0 deletions internal/emit/render_constraints.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
// The plan-time enforcement of the bounds a document declares. An API that
// silently clamps or truncates an out-of-range value leaves state and config
// disagreeing with no error to explain it; a validator turns that into a plan
// failure naming the attribute.

package emit

import (
"fmt"
"regexp"
"strconv"
"strings"

"github.com/deploymenttheory/terraform-plugin-framework-codegen/internal/code"
ir "github.com/deploymenttheory/terraform-plugin-framework-codegen/internal/intermediate_representation"
)

// validatorRoot is where the stock validator packages live.
const validatorPackageRoot = "github.com/hashicorp/terraform-plugin-framework-validators/"

// constraintValidators is every validator that follows from what the document
// declares about one attribute's value: its length, its size, its numeric
// range, and the pattern it must match.
//
// Nothing here registers an import; each expression carries the packages it
// references, and validatorLines is the single place those are honoured.
func constraintValidators(n node) []code.CustomValidator {
kind := n.attr.Kind
switch {
case kind == ir.TypeList || kind == ir.TypeMap:
// A size bound applies to the collection whether its elements are
// scalars or objects, so this is deliberately not gated on Nested.
return sizeValidators(n)
case n.attr.Nested != nil:
// An object declaring a length or a range is declaring it about
// something this attribute does not hold.
return nil
case kind == ir.TypeString:
return stringValidators(n)
case kind == ir.TypeInt64 || kind == ir.TypeFloat64:
return numericValidators(n)
}
return nil
}

// stringValidators are the length bounds and the pattern.
//
// Length is measured in characters rather than bytes: JSON Schema counts
// maxLength in characters, and the framework's LengthBetween counts bytes,
// which would refuse a valid value the moment it stopped being ASCII.
func stringValidators(n node) []code.CustomValidator {
var out []code.CustomValidator

if call, ok := boundCall("UTF8Length", "", n.attr.MinLength, n.attr.MaxLength); ok {
out = append(out, stockValidator("stringvalidator", call))
}
if pattern := n.attr.Pattern; pattern != "" {
if expression, ok := regexLiteral(pattern); ok {
out = append(out, code.CustomValidator{
Imports: []code.Import{
{Path: validatorPackageRoot + "stringvalidator"},
{Path: "regexp"},
},
SchemaDefinition: fmt.Sprintf("stringvalidator.RegexMatches(regexp.MustCompile(%s), %s)",
expression, strconv.Quote("must match "+pattern)),
})
}
}
return out
}

// numericValidators is the declared range, rendered against the attribute's
// own type so the literal the validator takes is the one it compares with.
func numericValidators(n node) []code.CustomValidator {
pkg := "float64validator"
format := func(v float64) string { return strconv.FormatFloat(v, 'f', -1, 64) }
minimum, maximum := n.attr.Minimum, n.attr.Maximum

if n.attr.Kind == ir.TypeInt64 {
pkg = "int64validator"
format = func(v float64) string { return strconv.FormatInt(int64(v), 10) }
// A fractional bound on an integer attribute describes a value the
// attribute cannot hold. Truncating it would silently move the
// boundary, so the bound is dropped and the other one still stands.
minimum, maximum = integralOnly(minimum), integralOnly(maximum)
}

var out []code.CustomValidator
if call, ok := boundCall("", "", formatted(minimum, format), formatted(maximum, format)); ok {
out = append(out, stockValidator(pkg, call))
}
return out
}

// sizeValidators is the declared member-count range of a list or a map.
func sizeValidators(n node) []code.CustomValidator {
pkg := "listvalidator"
if n.attr.Kind == ir.TypeMap {
pkg = "mapvalidator"
}
if call, ok := boundCall("Size", "Size", n.attr.MinItems, n.attr.MaxItems); ok {
return []code.CustomValidator{stockValidator(pkg, call)}
}
return nil
}

// boundCall renders the one call that states a pair of bounds: Between when
// both are declared, AtLeast or AtMost when one is. prefix names the family
// ("UTF8Length", "Size", or none for a plain range); betweenPrefix is the
// same for the two-sided spelling, because a size range is SizeBetween while
// a length range is UTF8LengthBetween and a numeric range is plain Between.
//
// The generic parameter is the literal's own type, so an int64 bound renders
// without a decimal point and a float64 bound keeps one.
func boundCall[T int64 | string](prefix, betweenPrefix string, minimum, maximum *T) (string, bool) {
if betweenPrefix == "" {
betweenPrefix = prefix
}
switch {
case minimum != nil && maximum != nil:
return fmt.Sprintf("%sBetween(%v, %v)", betweenPrefix, *minimum, *maximum), true
case minimum != nil:
return fmt.Sprintf("%sAtLeast(%v)", prefix, *minimum), true
case maximum != nil:
return fmt.Sprintf("%sAtMost(%v)", prefix, *maximum), true
}
return "", false
}

// stockValidator is one validator from a stock package, carrying that
// package as its only import.
func stockValidator(pkg, call string) code.CustomValidator {
return code.CustomValidator{
Imports: []code.Import{{Path: validatorPackageRoot + pkg}},
SchemaDefinition: pkg + "." + call,
}
}

// integralOnly passes a bound through only when it names a whole number.
func integralOnly(bound *float64) *float64 {
if bound == nil || *bound != float64(int64(*bound)) {
return nil
}
return bound
}

// formatted renders a numeric bound as the literal its validator takes.
func formatted(bound *float64, format func(float64) string) *string {
if bound == nil {
return nil
}
literal := format(*bound)
return &literal
}

// regexLiteral renders a declared pattern as a Go regexp literal, and reports
// whether Go can compile it at all.
//
// OpenAPI patterns are ECMA-262 and Go's regexp is RE2, which has no
// lookahead and no backreferences. An expression RE2 rejects would panic the
// generated provider inside MustCompile at package initialisation — before
// any test or plan runs, and where `go build` cannot see it — so one that
// does not compile here yields no validator at all.
func regexLiteral(pattern string) (string, bool) {
if _, err := regexp.Compile(pattern); err != nil {
return "", false
}
// A raw literal keeps a pattern's backslashes as written. It cannot hold
// a backtick or a carriage return, and an interpreted literal can hold
// either.
if !strings.ContainsAny(pattern, "`\r") {
return "`" + pattern + "`", true
}
return strconv.Quote(pattern), true
}
157 changes: 157 additions & 0 deletions internal/emit/render_constraints_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
package emit

import (
"strings"
"testing"

ir "github.com/deploymenttheory/terraform-plugin-framework-codegen/internal/intermediate_representation"
)

func i64(v int64) *int64 { return &v }
func f64(v float64) *float64 { return &v }
func constraintDecl(attr ir.Attribute) string {
return declOf(schemaResource, attr)
}

// TestUnit_ConstraintValidators_RenderTheDeclaredBounds proves each declared
// bound becomes the validator that enforces it at plan time, in the spelling
// its own package uses.
func TestUnit_ConstraintValidators_RenderTheDeclaredBounds(t *testing.T) {
for _, testCase := range []struct {
name string
attr ir.Attribute
want string
}{
{
"a string with both length bounds",
ir.Attribute{Name: "n", Kind: ir.TypeString, MinLength: i64(8), MaxLength: i64(64)},
"stringvalidator.UTF8LengthBetween(8, 64)",
},
{
"a string with only a minimum length",
ir.Attribute{Name: "n", Kind: ir.TypeString, MinLength: i64(8)},
"stringvalidator.UTF8LengthAtLeast(8)",
},
{
"a string with only a maximum length",
ir.Attribute{Name: "n", Kind: ir.TypeString, MaxLength: i64(64)},
"stringvalidator.UTF8LengthAtMost(64)",
},
{
"an integer range",
ir.Attribute{Name: "n", Kind: ir.TypeInt64, Minimum: f64(1), Maximum: f64(10)},
"int64validator.Between(1, 10)",
},
{
"an integer with only a maximum",
ir.Attribute{Name: "n", Kind: ir.TypeInt64, Maximum: f64(10)},
"int64validator.AtMost(10)",
},
{
"a float range keeps its fraction",
ir.Attribute{Name: "n", Kind: ir.TypeFloat64, Minimum: f64(0.5), Maximum: f64(99.5)},
"float64validator.Between(0.5, 99.5)",
},
{
"a list size range",
ir.Attribute{Name: "n", Kind: ir.TypeList, ElementType: ir.TypeString, MinItems: i64(1), MaxItems: i64(5)},
"listvalidator.SizeBetween(1, 5)",
},
{
"a map size floor",
ir.Attribute{Name: "n", Kind: ir.TypeMap, ElementType: ir.TypeString, MinItems: i64(1)},
"mapvalidator.SizeAtLeast(1)",
},
{
"a pattern becomes a compiled match",
ir.Attribute{Name: "n", Kind: ir.TypeString, Pattern: `^[a-z]+$`},
"stringvalidator.RegexMatches(regexp.MustCompile(`^[a-z]+$`), \"must match ^[a-z]+$\")",
},
} {
if decl := constraintDecl(testCase.attr); !strings.Contains(decl, testCase.want) {
t.Errorf("%s: does not carry %q:\n%s", testCase.name, testCase.want, decl)
}
}
}

// TestUnit_ConstraintValidators_SkipAPatternRE2CannotCompile proves a
// lookahead — legal in ECMA-262, rejected by RE2 — yields no validator at
// all. Emitting it would panic the generated provider inside MustCompile at
// package initialisation, where go build cannot see it.
func TestUnit_ConstraintValidators_SkipAPatternRE2CannotCompile(t *testing.T) {
decl := constraintDecl(ir.Attribute{
Name: "n", Kind: ir.TypeString, Pattern: `^(?=.*[A-Z]).{8,}$`,
})
if strings.Contains(decl, "RegexMatches") {
t.Errorf("a pattern RE2 cannot compile was emitted anyway:\n%s", decl)
}

// The rest of the attribute still stands: one unrenderable pattern is a
// fact about the pattern, not about the length bound beside it.
both := constraintDecl(ir.Attribute{
Name: "n", Kind: ir.TypeString, Pattern: `^(?=.*[A-Z]).{8,}$`, MaxLength: i64(64),
})
if !strings.Contains(both, "stringvalidator.UTF8LengthAtMost(64)") {
t.Errorf("an unrenderable pattern took the length bound with it:\n%s", both)
}
}

// TestUnit_ConstraintValidators_DropAFractionalBoundOnAnInteger proves a
// bound the attribute's type cannot hold is dropped rather than truncated,
// which would silently move the boundary.
func TestUnit_ConstraintValidators_DropAFractionalBoundOnAnInteger(t *testing.T) {
decl := constraintDecl(ir.Attribute{
Name: "n", Kind: ir.TypeInt64, Minimum: f64(1.5), Maximum: f64(10),
})
if strings.Contains(decl, "1.5") || strings.Contains(decl, "Between") {
t.Errorf("a fractional minimum survived onto an integer attribute:\n%s", decl)
}
if !strings.Contains(decl, "int64validator.AtMost(10)") {
t.Errorf("the integral bound beside it was dropped too:\n%s", decl)
}
}

// TestUnit_ConstraintValidators_LeaveANestedObjectAlone proves a length or a
// range declared on an object is not applied to the object: it describes
// something the attribute does not hold. A size bound on a nested list still
// applies, because the list is the thing being sized.
func TestUnit_ConstraintValidators_LeaveANestedObjectAlone(t *testing.T) {
nested := &ir.AttributeTree{Attributes: []ir.Attribute{
{Name: "inner", Kind: ir.TypeString, ComputedOptionalRequired: ir.Optional},
}}

object := constraintDecl(ir.Attribute{
Name: "n", Kind: ir.TypeObject, Nested: nested, MaxLength: i64(64), Maximum: f64(10),
})
if strings.Contains(object, "Validators:") {
t.Errorf("a bound was applied to an object:\n%s", object)
}

list := constraintDecl(ir.Attribute{
Name: "n", Kind: ir.TypeList, Nested: nested, MaxItems: i64(5),
})
if !strings.Contains(list, "listvalidator.SizeAtMost(5)") {
t.Errorf("a nested list was not sized:\n%s", list)
}
}

// TestUnit_ConstraintValidators_DeclareTheirOwnImports proves every rendered
// expression registered the package it names, so a validator can never reach
// a file whose import block forgot it.
func TestUnit_ConstraintValidators_DeclareTheirOwnImports(t *testing.T) {
sb := &schemaBuilder{kind: schemaResource, imports: newImportSet("example.com/m")}
sb.attributeDecl(node{attr: ir.Attribute{
Name: "n", Kind: ir.TypeString, MaxLength: i64(64), Pattern: `^[a-z]+$`,
}}, 0)

rendered := sb.imports.render()
for _, want := range []string{
"terraform-plugin-framework-validators/stringvalidator",
`"regexp"`,
"terraform-plugin-framework/schema/validator",
} {
if !strings.Contains(rendered, want) {
t.Errorf("the import block does not declare %q:\n%s", want, rendered)
}
}
}
7 changes: 5 additions & 2 deletions internal/emit/render_schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,9 @@ func (sb *schemaBuilder) attributeDecl(n node, depth int) string {

// validators is every stock validator one attribute carries, each a
// finished expression travelling with the imports it needs: the enum OneOf
// on a closed-set string, and the AlsoRequires that realizes a dependency
// whose subject is this root attribute.
// on a closed-set string, the bounds the document declares about the value,
// and the AlsoRequires that realizes a dependency whose subject is this root
// attribute.
//
// Nothing here registers an import. An expression that needs a package says
// so on the value it returns, and validatorLines is the single place those
Expand All @@ -159,6 +160,8 @@ func (sb *schemaBuilder) validators(n node, depth int) []code.CustomValidator {
})
}

validators = append(validators, constraintValidators(n)...)

if depth == sb.rootDepth {
if reqs, ok := sb.deps[n.attr.Name]; ok {
schema := schemaTypeOf(n)
Expand Down