configs: Add default argument to optional()

The optional modifier previously accepted a single argument: the
attribute type. This commit adds an optional second argument, which
specifies a default value for the attribute.

To record the default values for a variable's type, we use a separate
parallel structure of `typeexpr.Defaults`, rather than extending
`cty.Type` to include a `cty.Value` of defaults (which may in turn
include a `cty.Type` with defaults, and so on, and so forth).

The new `typeexpr.TypeConstraintWithDefaults` returns a type constraint
and defaults value. Defaults will be `nil` unless there are default
values specified somewhere in the variable's type.
This commit is contained in:
Alisdair McDiarmid 2022-04-21 15:09:44 -04:00
parent 8f69e36e1b
commit 650380f3ae
5 changed files with 435 additions and 59 deletions

View File

@ -27,6 +27,7 @@ type Variable struct {
// ConstraintType is used for decoding and type conversions, and may
// contain nested ObjectWithOptionalAttr types.
ConstraintType cty.Type
TypeDefaults *typeexpr.Defaults
ParsingMode VariableParsingMode
Validations []*CheckRule
@ -102,9 +103,10 @@ func decodeVariableBlock(block *hcl.Block, override bool) (*Variable, hcl.Diagno
}
if attr, exists := content.Attributes["type"]; exists {
ty, parseMode, tyDiags := decodeVariableType(attr.Expr)
ty, tyDefaults, parseMode, tyDiags := decodeVariableType(attr.Expr)
diags = append(diags, tyDiags...)
v.ConstraintType = ty
v.TypeDefaults = tyDefaults
v.Type = ty.WithoutOptionalAttributesDeep()
v.ParsingMode = parseMode
}
@ -137,6 +139,11 @@ func decodeVariableBlock(block *hcl.Block, override bool) (*Variable, hcl.Diagno
// the type might not be set; we'll catch that during merge.
if v.ConstraintType != cty.NilType {
var err error
// If the type constraint has defaults, we must apply those
// defaults to the variable default value before type conversion.
if v.TypeDefaults != nil {
val = v.TypeDefaults.Apply(val)
}
val, err = convert.Convert(val, v.ConstraintType)
if err != nil {
diags = append(diags, &hcl.Diagnostic{
@ -179,7 +186,7 @@ func decodeVariableBlock(block *hcl.Block, override bool) (*Variable, hcl.Diagno
return v, diags
}
func decodeVariableType(expr hcl.Expression) (cty.Type, VariableParsingMode, hcl.Diagnostics) {
func decodeVariableType(expr hcl.Expression) (cty.Type, *typeexpr.Defaults, VariableParsingMode, hcl.Diagnostics) {
if exprIsNativeQuotedString(expr) {
// If a user provides the pre-0.12 form of variable type argument where
// the string values "string", "list" and "map" are accepted, we
@ -190,7 +197,7 @@ func decodeVariableType(expr hcl.Expression) (cty.Type, VariableParsingMode, hcl
// in the normal codepath below.
val, diags := expr.Value(nil)
if diags.HasErrors() {
return cty.DynamicPseudoType, VariableParseHCL, diags
return cty.DynamicPseudoType, nil, VariableParseHCL, diags
}
str := val.AsString()
switch str {
@ -201,7 +208,7 @@ func decodeVariableType(expr hcl.Expression) (cty.Type, VariableParsingMode, hcl
Detail: "Terraform 0.11 and earlier required type constraints to be given in quotes, but that form is now deprecated and will be removed in a future version of Terraform. Remove the quotes around \"string\".",
Subject: expr.Range().Ptr(),
})
return cty.DynamicPseudoType, VariableParseLiteral, diags
return cty.DynamicPseudoType, nil, VariableParseLiteral, diags
case "list":
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
@ -209,7 +216,7 @@ func decodeVariableType(expr hcl.Expression) (cty.Type, VariableParsingMode, hcl
Detail: "Terraform 0.11 and earlier required type constraints to be given in quotes, but that form is now deprecated and will be removed in a future version of Terraform. Remove the quotes around \"list\" and write list(string) instead to explicitly indicate that the list elements are strings.",
Subject: expr.Range().Ptr(),
})
return cty.DynamicPseudoType, VariableParseHCL, diags
return cty.DynamicPseudoType, nil, VariableParseHCL, diags
case "map":
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
@ -217,9 +224,9 @@ func decodeVariableType(expr hcl.Expression) (cty.Type, VariableParsingMode, hcl
Detail: "Terraform 0.11 and earlier required type constraints to be given in quotes, but that form is now deprecated and will be removed in a future version of Terraform. Remove the quotes around \"map\" and write map(string) instead to explicitly indicate that the map elements are strings.",
Subject: expr.Range().Ptr(),
})
return cty.DynamicPseudoType, VariableParseHCL, diags
return cty.DynamicPseudoType, nil, VariableParseHCL, diags
default:
return cty.DynamicPseudoType, VariableParseHCL, hcl.Diagnostics{{
return cty.DynamicPseudoType, nil, VariableParseHCL, hcl.Diagnostics{{
Severity: hcl.DiagError,
Summary: "Invalid legacy variable type hint",
Detail: `To provide a full type expression, remove the surrounding quotes and give the type expression directly.`,
@ -234,23 +241,23 @@ func decodeVariableType(expr hcl.Expression) (cty.Type, VariableParsingMode, hcl
// elements are consistent. This is the same as list(any) or map(any).
switch hcl.ExprAsKeyword(expr) {
case "list":
return cty.List(cty.DynamicPseudoType), VariableParseHCL, nil
return cty.List(cty.DynamicPseudoType), nil, VariableParseHCL, nil
case "map":
return cty.Map(cty.DynamicPseudoType), VariableParseHCL, nil
return cty.Map(cty.DynamicPseudoType), nil, VariableParseHCL, nil
}
ty, diags := typeexpr.TypeConstraint(expr)
ty, typeDefaults, diags := typeexpr.TypeConstraintWithDefaults(expr)
if diags.HasErrors() {
return cty.DynamicPseudoType, VariableParseHCL, diags
return cty.DynamicPseudoType, nil, VariableParseHCL, diags
}
switch {
case ty.IsPrimitiveType():
// Primitive types use literal parsing.
return ty, VariableParseLiteral, diags
return ty, typeDefaults, VariableParseLiteral, diags
default:
// Everything else uses HCL parsing
return ty, VariableParseHCL, diags
return ty, typeDefaults, VariableParseHCL, diags
}
}

View File

@ -7,6 +7,7 @@ terraform {
variable "a" {
type = object({
foo = optional(string)
bar = optional(bool, true)
})
}

View File

@ -5,49 +5,52 @@ import (
"github.com/hashicorp/hcl/v2"
"github.com/zclconf/go-cty/cty"
"github.com/zclconf/go-cty/cty/convert"
)
const invalidTypeSummary = "Invalid type specification"
// getType is the internal implementation of both Type and TypeConstraint,
// using the passed flag to distinguish. When constraint is false, the "any"
// keyword will produce an error.
func getType(expr hcl.Expression, constraint bool) (cty.Type, hcl.Diagnostics) {
// getType is the internal implementation of Type, TypeConstraint, and
// TypeConstraintWithDefaults, using the passed flags to distinguish. When
// `constraint` is true, the "any" keyword can be used in place of a concrete
// type. When `withDefaults` is true, the "optional" call expression supports
// an additional argument describing a default value.
func getType(expr hcl.Expression, constraint, withDefaults bool) (cty.Type, *Defaults, hcl.Diagnostics) {
// First we'll try for one of our keywords
kw := hcl.ExprAsKeyword(expr)
switch kw {
case "bool":
return cty.Bool, nil
return cty.Bool, nil, nil
case "string":
return cty.String, nil
return cty.String, nil, nil
case "number":
return cty.Number, nil
return cty.Number, nil, nil
case "any":
if constraint {
return cty.DynamicPseudoType, nil
return cty.DynamicPseudoType, nil, nil
}
return cty.DynamicPseudoType, hcl.Diagnostics{{
return cty.DynamicPseudoType, nil, hcl.Diagnostics{{
Severity: hcl.DiagError,
Summary: invalidTypeSummary,
Detail: fmt.Sprintf("The keyword %q cannot be used in this type specification: an exact type is required.", kw),
Subject: expr.Range().Ptr(),
}}
case "list", "map", "set":
return cty.DynamicPseudoType, hcl.Diagnostics{{
return cty.DynamicPseudoType, nil, hcl.Diagnostics{{
Severity: hcl.DiagError,
Summary: invalidTypeSummary,
Detail: fmt.Sprintf("The %s type constructor requires one argument specifying the element type.", kw),
Subject: expr.Range().Ptr(),
}}
case "object":
return cty.DynamicPseudoType, hcl.Diagnostics{{
return cty.DynamicPseudoType, nil, hcl.Diagnostics{{
Severity: hcl.DiagError,
Summary: invalidTypeSummary,
Detail: "The object type constructor requires one argument specifying the attribute types and values as a map.",
Subject: expr.Range().Ptr(),
}}
case "tuple":
return cty.DynamicPseudoType, hcl.Diagnostics{{
return cty.DynamicPseudoType, nil, hcl.Diagnostics{{
Severity: hcl.DiagError,
Summary: invalidTypeSummary,
Detail: "The tuple type constructor requires one argument specifying the element types as a list.",
@ -56,7 +59,7 @@ func getType(expr hcl.Expression, constraint bool) (cty.Type, hcl.Diagnostics) {
case "":
// okay! we'll fall through and try processing as a call, then.
default:
return cty.DynamicPseudoType, hcl.Diagnostics{{
return cty.DynamicPseudoType, nil, hcl.Diagnostics{{
Severity: hcl.DiagError,
Summary: invalidTypeSummary,
Detail: fmt.Sprintf("The keyword %q is not a valid type specification.", kw),
@ -68,7 +71,7 @@ func getType(expr hcl.Expression, constraint bool) (cty.Type, hcl.Diagnostics) {
// try to process it as a call instead.
call, diags := hcl.ExprCall(expr)
if diags.HasErrors() {
return cty.DynamicPseudoType, hcl.Diagnostics{{
return cty.DynamicPseudoType, nil, hcl.Diagnostics{{
Severity: hcl.DiagError,
Summary: invalidTypeSummary,
Detail: "A type specification is either a primitive type keyword (bool, number, string) or a complex type constructor call, like list(string).",
@ -78,14 +81,14 @@ func getType(expr hcl.Expression, constraint bool) (cty.Type, hcl.Diagnostics) {
switch call.Name {
case "bool", "string", "number":
return cty.DynamicPseudoType, hcl.Diagnostics{{
return cty.DynamicPseudoType, nil, hcl.Diagnostics{{
Severity: hcl.DiagError,
Summary: invalidTypeSummary,
Detail: fmt.Sprintf("Primitive type keyword %q does not expect arguments.", call.Name),
Subject: &call.ArgsRange,
}}
case "any":
return cty.DynamicPseudoType, hcl.Diagnostics{{
return cty.DynamicPseudoType, nil, hcl.Diagnostics{{
Severity: hcl.DiagError,
Summary: invalidTypeSummary,
Detail: fmt.Sprintf("Type constraint keyword %q does not expect arguments.", call.Name),
@ -105,7 +108,7 @@ func getType(expr hcl.Expression, constraint bool) (cty.Type, hcl.Diagnostics) {
switch call.Name {
case "list", "set", "map":
return cty.DynamicPseudoType, hcl.Diagnostics{{
return cty.DynamicPseudoType, nil, hcl.Diagnostics{{
Severity: hcl.DiagError,
Summary: invalidTypeSummary,
Detail: fmt.Sprintf("The %s type constructor requires one argument specifying the element type.", call.Name),
@ -113,7 +116,7 @@ func getType(expr hcl.Expression, constraint bool) (cty.Type, hcl.Diagnostics) {
Context: &contextRange,
}}
case "object":
return cty.DynamicPseudoType, hcl.Diagnostics{{
return cty.DynamicPseudoType, nil, hcl.Diagnostics{{
Severity: hcl.DiagError,
Summary: invalidTypeSummary,
Detail: "The object type constructor requires one argument specifying the attribute types and values as a map.",
@ -121,7 +124,7 @@ func getType(expr hcl.Expression, constraint bool) (cty.Type, hcl.Diagnostics) {
Context: &contextRange,
}}
case "tuple":
return cty.DynamicPseudoType, hcl.Diagnostics{{
return cty.DynamicPseudoType, nil, hcl.Diagnostics{{
Severity: hcl.DiagError,
Summary: invalidTypeSummary,
Detail: "The tuple type constructor requires one argument specifying the element types as a list.",
@ -134,18 +137,21 @@ func getType(expr hcl.Expression, constraint bool) (cty.Type, hcl.Diagnostics) {
switch call.Name {
case "list":
ety, diags := getType(call.Arguments[0], constraint)
return cty.List(ety), diags
ety, defaults, diags := getType(call.Arguments[0], constraint, withDefaults)
ty := cty.List(ety)
return ty, collectionDefaults(ty, defaults), diags
case "set":
ety, diags := getType(call.Arguments[0], constraint)
return cty.Set(ety), diags
ety, defaults, diags := getType(call.Arguments[0], constraint, withDefaults)
ty := cty.Set(ety)
return ty, collectionDefaults(ty, defaults), diags
case "map":
ety, diags := getType(call.Arguments[0], constraint)
return cty.Map(ety), diags
ety, defaults, diags := getType(call.Arguments[0], constraint, withDefaults)
ty := cty.Map(ety)
return ty, collectionDefaults(ty, defaults), diags
case "object":
attrDefs, diags := hcl.ExprMap(call.Arguments[0])
if diags.HasErrors() {
return cty.DynamicPseudoType, hcl.Diagnostics{{
return cty.DynamicPseudoType, nil, hcl.Diagnostics{{
Severity: hcl.DiagError,
Summary: invalidTypeSummary,
Detail: "Object type constructor requires a map whose keys are attribute names and whose values are the corresponding attribute types.",
@ -155,6 +161,8 @@ func getType(expr hcl.Expression, constraint bool) (cty.Type, hcl.Diagnostics) {
}
atys := make(map[string]cty.Type)
defaultValues := make(map[string]cty.Value)
children := make(map[string]*Defaults)
var optAttrs []string
for _, attrDef := range attrDefs {
attrName := hcl.ExprAsKeyword(attrDef.Key)
@ -174,6 +182,7 @@ func getType(expr hcl.Expression, constraint bool) (cty.Type, hcl.Diagnostics) {
// modifier optional(...) to indicate an optional attribute. If
// so, we'll unwrap that first and make a note about it being
// optional for when we construct the type below.
var defaultExpr hcl.Expression
if call, callDiags := hcl.ExprCall(atyExpr); !callDiags.HasErrors() {
if call.Name == "optional" {
if len(call.Arguments) < 1 {
@ -187,16 +196,40 @@ func getType(expr hcl.Expression, constraint bool) (cty.Type, hcl.Diagnostics) {
continue
}
if constraint {
if len(call.Arguments) > 1 {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: invalidTypeSummary,
Detail: "Optional attribute modifier expects only one argument: the attribute type.",
Subject: call.ArgsRange.Ptr(),
Context: atyExpr.Range().Ptr(),
})
if withDefaults {
switch len(call.Arguments) {
case 2:
defaultExpr = call.Arguments[1]
defaultVal, defaultDiags := defaultExpr.Value(nil)
diags = append(diags, defaultDiags...)
if !defaultDiags.HasErrors() {
optAttrs = append(optAttrs, attrName)
defaultValues[attrName] = defaultVal
}
case 1:
optAttrs = append(optAttrs, attrName)
default:
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: invalidTypeSummary,
Detail: "Optional attribute modifier expects at most two arguments: the attribute type, and a default value.",
Subject: call.ArgsRange.Ptr(),
Context: atyExpr.Range().Ptr(),
})
}
} else {
if len(call.Arguments) == 1 {
optAttrs = append(optAttrs, attrName)
} else {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: invalidTypeSummary,
Detail: "Optional attribute modifier expects only one argument: the attribute type.",
Subject: call.ArgsRange.Ptr(),
Context: atyExpr.Range().Ptr(),
})
}
}
optAttrs = append(optAttrs, attrName)
} else {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
@ -210,19 +243,39 @@ func getType(expr hcl.Expression, constraint bool) (cty.Type, hcl.Diagnostics) {
}
}
aty, attrDiags := getType(atyExpr, constraint)
aty, aDefaults, attrDiags := getType(atyExpr, constraint, withDefaults)
diags = append(diags, attrDiags...)
// If a default is set for an optional attribute, verify that it is
// convertible to the attribute type.
if defaultVal, ok := defaultValues[attrName]; ok {
_, err := convert.Convert(defaultVal, aty)
if err != nil {
diags = append(diags, &hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: "Invalid default value for optional attribute",
Detail: fmt.Sprintf("This default value is not compatible with the attribute's type constraint: %s.", err),
Subject: defaultExpr.Range().Ptr(),
})
delete(defaultValues, attrName)
}
}
atys[attrName] = aty
if aDefaults != nil {
children[attrName] = aDefaults
}
}
// NOTE: ObjectWithOptionalAttrs is experimental in cty at the
// time of writing, so this interface might change even in future
// minor versions of cty. We're accepting that because Terraform
// itself is considering optional attributes as experimental right now.
return cty.ObjectWithOptionalAttrs(atys, optAttrs), diags
ty := cty.ObjectWithOptionalAttrs(atys, optAttrs)
return ty, structuredDefaults(ty, defaultValues, children), diags
case "tuple":
elemDefs, diags := hcl.ExprList(call.Arguments[0])
if diags.HasErrors() {
return cty.DynamicPseudoType, hcl.Diagnostics{{
return cty.DynamicPseudoType, nil, hcl.Diagnostics{{
Severity: hcl.DiagError,
Summary: invalidTypeSummary,
Detail: "Tuple type constructor requires a list of element types.",
@ -231,14 +284,19 @@ func getType(expr hcl.Expression, constraint bool) (cty.Type, hcl.Diagnostics) {
}}
}
etys := make([]cty.Type, len(elemDefs))
children := make(map[string]*Defaults, len(elemDefs))
for i, defExpr := range elemDefs {
ety, elemDiags := getType(defExpr, constraint)
ety, elemDefaults, elemDiags := getType(defExpr, constraint, withDefaults)
diags = append(diags, elemDiags...)
etys[i] = ety
if elemDefaults != nil {
children[fmt.Sprintf("%d", i)] = elemDefaults
}
}
return cty.Tuple(etys), diags
ty := cty.Tuple(etys)
return ty, structuredDefaults(ty, nil, children), diags
case "optional":
return cty.DynamicPseudoType, hcl.Diagnostics{{
return cty.DynamicPseudoType, nil, hcl.Diagnostics{{
Severity: hcl.DiagError,
Summary: invalidTypeSummary,
Detail: fmt.Sprintf("Keyword %q is valid only as a modifier for object type attributes.", call.Name),
@ -247,7 +305,7 @@ func getType(expr hcl.Expression, constraint bool) (cty.Type, hcl.Diagnostics) {
default:
// Can't access call.Arguments in this path because we've not validated
// that it contains exactly one expression here.
return cty.DynamicPseudoType, hcl.Diagnostics{{
return cty.DynamicPseudoType, nil, hcl.Diagnostics{{
Severity: hcl.DiagError,
Summary: invalidTypeSummary,
Detail: fmt.Sprintf("Keyword %q is not a valid type constructor.", call.Name),
@ -255,3 +313,33 @@ func getType(expr hcl.Expression, constraint bool) (cty.Type, hcl.Diagnostics) {
}}
}
}
func collectionDefaults(ty cty.Type, defaults *Defaults) *Defaults {
if defaults == nil {
return nil
}
return &Defaults{
Type: ty,
Children: map[string]*Defaults{
"": defaults,
},
}
}
func structuredDefaults(ty cty.Type, defaultValues map[string]cty.Value, children map[string]*Defaults) *Defaults {
if len(defaultValues) == 0 && len(children) == 0 {
return nil
}
defaults := &Defaults{
Type: ty,
}
if len(defaultValues) > 0 {
defaults.DefaultValues = defaultValues
}
if len(children) > 0 {
defaults.Children = children
}
return defaults
}

View File

@ -6,12 +6,17 @@ import (
"github.com/hashicorp/hcl/v2/gohcl"
"github.com/google/go-cmp/cmp"
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/hclsyntax"
"github.com/hashicorp/hcl/v2/json"
"github.com/zclconf/go-cty/cty"
)
var (
typeComparer = cmp.Comparer(cty.Type.Equals)
)
func TestGetType(t *testing.T) {
tests := []struct {
Source string
@ -284,6 +289,23 @@ func TestGetType(t *testing.T) {
}),
`Optional attribute modifier is only for type constraints, not for exact types.`,
},
{
`object({name=string,meta=optional()})`,
true,
cty.Object(map[string]cty.Type{
"name": cty.String,
}),
`Optional attribute modifier requires the attribute type as its argument.`,
},
{
`object({name=string,meta=optional(string, "hello")})`,
true,
cty.Object(map[string]cty.Type{
"name": cty.String,
"meta": cty.String,
}),
`Optional attribute modifier expects only one argument: the attribute type.`,
},
{
`optional(string)`,
false,
@ -305,7 +327,7 @@ func TestGetType(t *testing.T) {
t.Fatalf("failed to parse: %s", diags)
}
got, diags := getType(expr, test.Constraint)
got, _, diags := getType(expr, test.Constraint, false)
if test.WantError == "" {
for _, diag := range diags {
t.Error(diag)
@ -377,7 +399,7 @@ func TestGetTypeJSON(t *testing.T) {
t.Fatalf("failed to decode: %s", diags)
}
got, diags := getType(content.Expr, test.Constraint)
got, _, diags := getType(content.Expr, test.Constraint, false)
if test.WantError == "" {
for _, diag := range diags {
t.Error(diag)
@ -401,3 +423,247 @@ func TestGetTypeJSON(t *testing.T) {
})
}
}
func TestGetTypeDefaults(t *testing.T) {
tests := []struct {
Source string
Want *Defaults
WantError string
}{
// primitive types have nil defaults
{
`bool`,
nil,
"",
},
{
`number`,
nil,
"",
},
{
`string`,
nil,
"",
},
{
`any`,
nil,
"",
},
// complex structures with no defaults have nil defaults
{
`map(string)`,
nil,
"",
},
{
`set(number)`,
nil,
"",
},
{
`tuple([number, string])`,
nil,
"",
},
{
`object({ a = string, b = number })`,
nil,
"",
},
{
`map(list(object({ a = string, b = optional(number) })))`,
nil,
"",
},
// object optional attribute with defaults
{
`object({ a = string, b = optional(number, 5) })`,
&Defaults{
Type: cty.ObjectWithOptionalAttrs(map[string]cty.Type{
"a": cty.String,
"b": cty.Number,
}, []string{"b"}),
DefaultValues: map[string]cty.Value{
"b": cty.NumberIntVal(5),
},
},
"",
},
// nested defaults
{
`object({ a = optional(object({ b = optional(number, 5) }), {}) })`,
&Defaults{
Type: cty.ObjectWithOptionalAttrs(map[string]cty.Type{
"a": cty.ObjectWithOptionalAttrs(map[string]cty.Type{
"b": cty.Number,
}, []string{"b"}),
}, []string{"a"}),
DefaultValues: map[string]cty.Value{
"a": cty.EmptyObjectVal,
},
Children: map[string]*Defaults{
"a": {
Type: cty.ObjectWithOptionalAttrs(map[string]cty.Type{
"b": cty.Number,
}, []string{"b"}),
DefaultValues: map[string]cty.Value{
"b": cty.NumberIntVal(5),
},
},
},
},
"",
},
// collections of objects with defaults
{
`map(object({ a = string, b = optional(number, 5) }))`,
&Defaults{
Type: cty.Map(cty.ObjectWithOptionalAttrs(map[string]cty.Type{
"a": cty.String,
"b": cty.Number,
}, []string{"b"})),
Children: map[string]*Defaults{
"": {
Type: cty.ObjectWithOptionalAttrs(map[string]cty.Type{
"a": cty.String,
"b": cty.Number,
}, []string{"b"}),
DefaultValues: map[string]cty.Value{
"b": cty.NumberIntVal(5),
},
},
},
},
"",
},
{
`list(object({ a = string, b = optional(number, 5) }))`,
&Defaults{
Type: cty.List(cty.ObjectWithOptionalAttrs(map[string]cty.Type{
"a": cty.String,
"b": cty.Number,
}, []string{"b"})),
Children: map[string]*Defaults{
"": {
Type: cty.ObjectWithOptionalAttrs(map[string]cty.Type{
"a": cty.String,
"b": cty.Number,
}, []string{"b"}),
DefaultValues: map[string]cty.Value{
"b": cty.NumberIntVal(5),
},
},
},
},
"",
},
{
`set(object({ a = string, b = optional(number, 5) }))`,
&Defaults{
Type: cty.Set(cty.ObjectWithOptionalAttrs(map[string]cty.Type{
"a": cty.String,
"b": cty.Number,
}, []string{"b"})),
Children: map[string]*Defaults{
"": {
Type: cty.ObjectWithOptionalAttrs(map[string]cty.Type{
"a": cty.String,
"b": cty.Number,
}, []string{"b"}),
DefaultValues: map[string]cty.Value{
"b": cty.NumberIntVal(5),
},
},
},
},
"",
},
// tuples containing objects with defaults work differently from
// collections
{
`tuple([string, bool, object({ a = string, b = optional(number, 5) })])`,
&Defaults{
Type: cty.Tuple([]cty.Type{
cty.String,
cty.Bool,
cty.ObjectWithOptionalAttrs(map[string]cty.Type{
"a": cty.String,
"b": cty.Number,
}, []string{"b"}),
}),
Children: map[string]*Defaults{
"2": {
Type: cty.ObjectWithOptionalAttrs(map[string]cty.Type{
"a": cty.String,
"b": cty.Number,
}, []string{"b"}),
DefaultValues: map[string]cty.Value{
"b": cty.NumberIntVal(5),
},
},
},
},
"",
},
// incompatible default value causes an error
{
`object({ a = optional(string, "hello"), b = optional(number, true) })`,
&Defaults{
Type: cty.ObjectWithOptionalAttrs(map[string]cty.Type{
"a": cty.String,
"b": cty.Number,
}, []string{"a", "b"}),
DefaultValues: map[string]cty.Value{
"a": cty.StringVal("hello"),
},
},
"This default value is not compatible with the attribute's type constraint: number required.",
},
// Too many arguments
{
`object({name=string,meta=optional(string, "hello", "world")})`,
nil,
`Optional attribute modifier expects at most two arguments: the attribute type, and a default value.`,
},
}
for _, test := range tests {
t.Run(test.Source, func(t *testing.T) {
expr, diags := hclsyntax.ParseExpression([]byte(test.Source), "", hcl.Pos{Line: 1, Column: 1})
if diags.HasErrors() {
t.Fatalf("failed to parse: %s", diags)
}
_, got, diags := getType(expr, true, true)
if test.WantError == "" {
for _, diag := range diags {
t.Error(diag)
}
} else {
found := false
for _, diag := range diags {
t.Log(diag)
if diag.Severity == hcl.DiagError && diag.Detail == test.WantError {
found = true
}
}
if !found {
t.Errorf("missing expected error detail message: %s", test.WantError)
}
}
if !cmp.Equal(test.Want, got, valueComparer, typeComparer) {
t.Errorf("wrong result\n%s", cmp.Diff(test.Want, got, valueComparer, typeComparer))
}
})
}
}

View File

@ -15,7 +15,8 @@ import (
// successful, returns the resulting type. If unsuccessful, error diagnostics
// are returned.
func Type(expr hcl.Expression) (cty.Type, hcl.Diagnostics) {
return getType(expr, false)
ty, _, diags := getType(expr, false, false)
return ty, diags
}
// TypeConstraint attempts to parse the given expression as a type constraint
@ -26,7 +27,20 @@ func Type(expr hcl.Expression) (cty.Type, hcl.Diagnostics) {
// allows the keyword "any" to represent cty.DynamicPseudoType, which is often
// used as a wildcard in type checking and type conversion operations.
func TypeConstraint(expr hcl.Expression) (cty.Type, hcl.Diagnostics) {
return getType(expr, true)
ty, _, diags := getType(expr, true, false)
return ty, diags
}
// TypeConstraintWithDefaults attempts to parse the given expression as a type
// constraint which may include default values for object attributes. If
// successful both the resulting type and corresponding defaults are returned.
// If unsuccessful, error diagnostics are returned.
//
// When using this function, defaults should be applied to the input value
// before type conversion, to ensure that objects with missing attributes have
// default values populated.
func TypeConstraintWithDefaults(expr hcl.Expression) (cty.Type, *Defaults, hcl.Diagnostics) {
return getType(expr, true, true)
}
// TypeString returns a string rendering of the given type as it would be