diff --git a/internal/emit/render_constraints.go b/internal/emit/render_constraints.go new file mode 100644 index 0000000..51c8cd0 --- /dev/null +++ b/internal/emit/render_constraints.go @@ -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 +} diff --git a/internal/emit/render_constraints_test.go b/internal/emit/render_constraints_test.go new file mode 100644 index 0000000..0923f3c --- /dev/null +++ b/internal/emit/render_constraints_test.go @@ -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) + } + } +} diff --git a/internal/emit/render_schema.go b/internal/emit/render_schema.go index 9b4cc0d..935fea2 100644 --- a/internal/emit/render_schema.go +++ b/internal/emit/render_schema.go @@ -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 @@ -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)