diff --git a/ext/typeexpr/defaults.go b/ext/typeexpr/defaults.go new file mode 100644 index 00000000..851c72fb --- /dev/null +++ b/ext/typeexpr/defaults.go @@ -0,0 +1,157 @@ +package typeexpr + +import ( + "github.com/zclconf/go-cty/cty" +) + +// Defaults represents a type tree which may contain default values for +// optional object attributes at any level. This is used to apply nested +// defaults to an input value before converting it to the concrete type. +type Defaults struct { + // Type of the node for which these defaults apply. This is necessary in + // order to determine how to inspect the Defaults and Children collections. + Type cty.Type + + // DefaultValues contains the default values for each object attribute, + // indexed by attribute name. + DefaultValues map[string]cty.Value + + // Children is a map of Defaults for elements contained in this type. This + // only applies to structural and collection types. + // + // The map is indexed by string instead of cty.Value because cty.Number + // instances are non-comparable, due to embedding a *big.Float. + // + // Collections have a single element type, which is stored at key "". + Children map[string]*Defaults +} + +// Apply walks the given value, applying specified defaults wherever optional +// attributes are missing. The input and output values may have different +// types, and the result may still require type conversion to the final desired +// type. +// +// This function is permissive and does not report errors, assuming that the +// caller will have better context to report useful type conversion failure +// diagnostics. +func (d *Defaults) Apply(val cty.Value) cty.Value { + val, err := cty.TransformWithTransformer(val, &defaultsTransformer{defaults: d}) + + // The transformer should never return an error. + if err != nil { + panic(err) + } + + return val +} + +// defaultsTransformer implements cty.Transformer, as a pre-order traversal, +// applying defaults as it goes. The pre-order traversal allows us to specify +// defaults more loosely for structural types, as the defaults for the types +// will be applied to the default value later in the walk. +type defaultsTransformer struct { + defaults *Defaults +} + +var _ cty.Transformer = (*defaultsTransformer)(nil) + +func (t *defaultsTransformer) Enter(p cty.Path, v cty.Value) (cty.Value, error) { + // Cannot apply defaults to an unknown value + if !v.IsKnown() { + return v, nil + } + + // Look up the defaults for this path. + defaults := t.defaults.traverse(p) + + // If we have no defaults, nothing to do. + if len(defaults) == 0 { + return v, nil + } + + // Ensure we are working with an object or map. + vt := v.Type() + if !vt.IsObjectType() && !vt.IsMapType() { + // Cannot apply defaults because the value type is incompatible. + // We'll ignore this and let the later conversion stage display a + // more useful diagnostic. + return v, nil + } + + // Unmark the value and reapply the marks later. + v, valMarks := v.Unmark() + + // Convert the given value into an attribute map (if it's non-null and + // non-empty). + attrs := make(map[string]cty.Value) + if !v.IsNull() && v.LengthInt() > 0 { + attrs = v.AsValueMap() + } + + // Apply defaults where attributes are missing, constructing a new + // value with the same marks. + for attr, defaultValue := range defaults { + if attrValue, ok := attrs[attr]; !ok || attrValue.IsNull() { + attrs[attr] = defaultValue + } + } + + // We construct an object even if the input value was a map, as the + // type of an attribute's default value may be incompatible with the + // map element type. + return cty.ObjectVal(attrs).WithMarks(valMarks), nil +} + +func (t *defaultsTransformer) Exit(p cty.Path, v cty.Value) (cty.Value, error) { + return v, nil +} + +// traverse walks the abstract defaults structure for a given path, returning +// a set of default values (if any are present) or nil (if not). This operation +// differs from applying a path to a value because we need to customize the +// traversal steps for collection types, where a single set of defaults can be +// applied to an arbitrary number of elements. +func (d *Defaults) traverse(path cty.Path) map[string]cty.Value { + if len(path) == 0 { + return d.DefaultValues + } + + switch s := path[0].(type) { + case cty.GetAttrStep: + if d.Type.IsObjectType() { + // Attribute path steps are normally applied to objects, where each + // attribute may have different defaults. + return d.traverseChild(s.Name, path) + } else if d.Type.IsMapType() { + // Literal values for maps can result in attribute path steps, in which + // case we need to disregard the attribute name, as maps can have only + // one child. + return d.traverseChild("", path) + } + + return nil + case cty.IndexStep: + if d.Type.IsTupleType() { + // Tuples can have different types for each element, so we look + // up the defaults based on the index key. + return d.traverseChild(s.Key.AsBigFloat().String(), path) + } else if d.Type.IsCollectionType() { + // Defaults for collection element types are stored with a blank + // key, so we disregard the index key. + return d.traverseChild("", path) + } + return nil + default: + // At time of writing there are no other path step types. + return nil + } +} + +// traverseChild continues the traversal for a given child key, and mutually +// recurses with traverse. +func (d *Defaults) traverseChild(name string, path cty.Path) map[string]cty.Value { + if child, ok := d.Children[name]; ok { + return child.traverse(path[1:]) + } + return nil +} diff --git a/ext/typeexpr/defaults_test.go b/ext/typeexpr/defaults_test.go new file mode 100644 index 00000000..a4da6bb6 --- /dev/null +++ b/ext/typeexpr/defaults_test.go @@ -0,0 +1,504 @@ +package typeexpr + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/zclconf/go-cty/cty" +) + +var ( + valueComparer = cmp.Comparer(cty.Value.RawEquals) +) + +func TestDefaults_Apply(t *testing.T) { + simpleObject := cty.ObjectWithOptionalAttrs(map[string]cty.Type{ + "a": cty.String, + "b": cty.Bool, + }, []string{"b"}) + nestedObject := cty.ObjectWithOptionalAttrs(map[string]cty.Type{ + "c": simpleObject, + "d": cty.Number, + }, []string{"c"}) + + testCases := map[string]struct { + defaults *Defaults + value cty.Value + want cty.Value + }{ + // Nothing happens when there are no default values and no children. + "no defaults": { + defaults: &Defaults{ + Type: cty.Map(cty.String), + }, + value: cty.MapVal(map[string]cty.Value{ + "a": cty.StringVal("foo"), + "b": cty.StringVal("bar"), + }), + want: cty.MapVal(map[string]cty.Value{ + "a": cty.StringVal("foo"), + "b": cty.StringVal("bar"), + }), + }, + // Passing a map which does not include one of the attributes with a + // default results in the default being applied to the output. Output + // is always an object. + "simple object with defaults applied": { + defaults: &Defaults{ + Type: simpleObject, + DefaultValues: map[string]cty.Value{ + "b": cty.True, + }, + }, + value: cty.MapVal(map[string]cty.Value{ + "a": cty.StringVal("foo"), + }), + want: cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("foo"), + "b": cty.True, + }), + }, + // Unknown values may be assigned to root modules during validation, + // and we cannot apply defaults at that time. + "simple object with defaults but unknown value": { + defaults: &Defaults{ + Type: simpleObject, + DefaultValues: map[string]cty.Value{ + "b": cty.True, + }, + }, + value: cty.UnknownVal(cty.Map(cty.String)), + want: cty.UnknownVal(cty.Map(cty.String)), + }, + // Defaults do not override attributes which are present in the given + // value. + "simple object with optional attributes specified": { + defaults: &Defaults{ + Type: simpleObject, + DefaultValues: map[string]cty.Value{ + "b": cty.True, + }, + }, + value: cty.MapVal(map[string]cty.Value{ + "a": cty.StringVal("foo"), + "b": cty.StringVal("false"), + }), + want: cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("foo"), + "b": cty.StringVal("false"), + }), + }, + // Defaults will replace explicit nulls. + "object with explicit null for attribute with default": { + defaults: &Defaults{ + Type: simpleObject, + DefaultValues: map[string]cty.Value{ + "b": cty.True, + }, + }, + value: cty.MapVal(map[string]cty.Value{ + "a": cty.StringVal("foo"), + "b": cty.NullVal(cty.String), + }), + want: cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("foo"), + "b": cty.True, + }), + }, + // Defaults can be specified at any level of depth and will be applied + // so long as there is a parent value to populate. + "nested object with defaults applied": { + defaults: &Defaults{ + Type: nestedObject, + Children: map[string]*Defaults{ + "c": { + Type: simpleObject, + DefaultValues: map[string]cty.Value{ + "b": cty.False, + }, + }, + }, + }, + value: cty.ObjectVal(map[string]cty.Value{ + "c": cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("foo"), + }), + "d": cty.NumberIntVal(5), + }), + want: cty.ObjectVal(map[string]cty.Value{ + "c": cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("foo"), + "b": cty.False, + }), + "d": cty.NumberIntVal(5), + }), + }, + // Testing traversal of collections. + "map of objects with defaults applied": { + defaults: &Defaults{ + Type: cty.Map(simpleObject), + Children: map[string]*Defaults{ + "": { + Type: simpleObject, + DefaultValues: map[string]cty.Value{ + "b": cty.True, + }, + }, + }, + }, + value: cty.MapVal(map[string]cty.Value{ + "f": cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("foo"), + }), + "b": cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("bar"), + }), + }), + want: cty.MapVal(map[string]cty.Value{ + "f": cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("foo"), + "b": cty.True, + }), + "b": cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("bar"), + "b": cty.True, + }), + }), + }, + // A map variable value specified in a tfvars file will be an object, + // in which case we must still traverse the defaults structure + // correctly. + "map of objects with defaults applied, given object instead of map": { + defaults: &Defaults{ + Type: cty.Map(simpleObject), + Children: map[string]*Defaults{ + "": { + Type: simpleObject, + DefaultValues: map[string]cty.Value{ + "b": cty.True, + }, + }, + }, + }, + value: cty.ObjectVal(map[string]cty.Value{ + "f": cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("foo"), + }), + "b": cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("bar"), + }), + }), + want: cty.ObjectVal(map[string]cty.Value{ + "f": cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("foo"), + "b": cty.True, + }), + "b": cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("bar"), + "b": cty.True, + }), + }), + }, + // Another example of a collection type, this time exercising the code + // processing a tuple input. + "list of objects with defaults applied": { + defaults: &Defaults{ + Type: cty.List(simpleObject), + Children: map[string]*Defaults{ + "": { + Type: simpleObject, + DefaultValues: map[string]cty.Value{ + "b": cty.True, + }, + }, + }, + }, + value: cty.TupleVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("foo"), + }), + cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("bar"), + }), + }), + want: cty.TupleVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("foo"), + "b": cty.True, + }), + cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("bar"), + "b": cty.True, + }), + }), + }, + // Unlike collections, tuple variable types can have defaults for + // multiple element types. + "tuple of objects with defaults applied": { + defaults: &Defaults{ + Type: cty.Tuple([]cty.Type{simpleObject, nestedObject}), + Children: map[string]*Defaults{ + "0": { + Type: simpleObject, + DefaultValues: map[string]cty.Value{ + "b": cty.False, + }, + }, + "1": { + Type: nestedObject, + DefaultValues: map[string]cty.Value{ + "c": cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("default"), + "b": cty.True, + }), + }, + }, + }, + }, + value: cty.TupleVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("foo"), + }), + cty.ObjectVal(map[string]cty.Value{ + "d": cty.NumberIntVal(5), + }), + }), + want: cty.TupleVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("foo"), + "b": cty.False, + }), + cty.ObjectVal(map[string]cty.Value{ + "c": cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("default"), + "b": cty.True, + }), + "d": cty.NumberIntVal(5), + }), + }), + }, + // More complex cases with deeply nested defaults, testing the "default + // within a default" edges. + "set of nested objects, no default sub-object": { + defaults: &Defaults{ + Type: cty.Set(nestedObject), + Children: map[string]*Defaults{ + "": { + Type: nestedObject, + Children: map[string]*Defaults{ + "c": { + Type: simpleObject, + DefaultValues: map[string]cty.Value{ + "b": cty.True, + }, + }, + }, + }, + }, + }, + value: cty.TupleVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "c": cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("foo"), + }), + "d": cty.NumberIntVal(5), + }), + cty.ObjectVal(map[string]cty.Value{ + "d": cty.NumberIntVal(7), + }), + }), + want: cty.TupleVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "c": cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("foo"), + "b": cty.True, + }), + "d": cty.NumberIntVal(5), + }), + cty.ObjectVal(map[string]cty.Value{ + // No default value for "c" specified, so none applied. The + // convert stage will fill in a null. + "d": cty.NumberIntVal(7), + }), + }), + }, + "set of nested objects, empty default sub-object": { + defaults: &Defaults{ + Type: cty.Set(nestedObject), + Children: map[string]*Defaults{ + "": { + Type: nestedObject, + DefaultValues: map[string]cty.Value{ + // This is a convenient shorthand which causes a + // missing sub-object to be filled with an object + // with all of the default values specified in the + // sub-object's type. + "c": cty.EmptyObjectVal, + }, + Children: map[string]*Defaults{ + "c": { + Type: simpleObject, + DefaultValues: map[string]cty.Value{ + "b": cty.True, + }, + }, + }, + }, + }, + }, + value: cty.TupleVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "c": cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("foo"), + }), + "d": cty.NumberIntVal(5), + }), + cty.ObjectVal(map[string]cty.Value{ + "d": cty.NumberIntVal(7), + }), + }), + want: cty.TupleVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "c": cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("foo"), + "b": cty.True, + }), + "d": cty.NumberIntVal(5), + }), + cty.ObjectVal(map[string]cty.Value{ + "c": cty.ObjectVal(map[string]cty.Value{ + // Default value for "b" is applied to the empty object + // specified as the default for "c" + "b": cty.True, + }), + "d": cty.NumberIntVal(7), + }), + }), + }, + "set of nested objects, overriding default sub-object": { + defaults: &Defaults{ + Type: cty.Set(nestedObject), + Children: map[string]*Defaults{ + "": { + Type: nestedObject, + DefaultValues: map[string]cty.Value{ + // If no value is given for "c", we use this object + // of non-default values instead. These take + // precedence over the default values specified in + // the child type. + "c": cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("fallback"), + "b": cty.False, + }), + }, + Children: map[string]*Defaults{ + "c": { + Type: simpleObject, + DefaultValues: map[string]cty.Value{ + "b": cty.True, + }, + }, + }, + }, + }, + }, + value: cty.TupleVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "c": cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("foo"), + }), + "d": cty.NumberIntVal(5), + }), + cty.ObjectVal(map[string]cty.Value{ + "d": cty.NumberIntVal(7), + }), + }), + want: cty.TupleVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "c": cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("foo"), + "b": cty.True, + }), + "d": cty.NumberIntVal(5), + }), + cty.ObjectVal(map[string]cty.Value{ + "c": cty.ObjectVal(map[string]cty.Value{ + // The default value for "b" is not applied, as the + // default value for "c" includes a non-default value + // already. + "a": cty.StringVal("fallback"), + "b": cty.False, + }), + "d": cty.NumberIntVal(7), + }), + }), + }, + "set of nested objects, nulls in default sub-object overridden": { + defaults: &Defaults{ + Type: cty.Set(nestedObject), + Children: map[string]*Defaults{ + "": { + Type: nestedObject, + DefaultValues: map[string]cty.Value{ + // The default value for "c" is used to prepopulate + // the nested object's value if not specified, but + // the null default for its "b" attribute will be + // overridden by the default specified in the child + // type. + "c": cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("fallback"), + "b": cty.NullVal(cty.Bool), + }), + }, + Children: map[string]*Defaults{ + "c": { + Type: simpleObject, + DefaultValues: map[string]cty.Value{ + "b": cty.True, + }, + }, + }, + }, + }, + }, + value: cty.TupleVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "c": cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("foo"), + }), + "d": cty.NumberIntVal(5), + }), + cty.ObjectVal(map[string]cty.Value{ + "d": cty.NumberIntVal(7), + }), + }), + want: cty.TupleVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "c": cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("foo"), + "b": cty.True, + }), + "d": cty.NumberIntVal(5), + }), + cty.ObjectVal(map[string]cty.Value{ + "c": cty.ObjectVal(map[string]cty.Value{ + // The default value for "b" overrides the explicit + // null in the default value for "c". + "a": cty.StringVal("fallback"), + "b": cty.True, + }), + "d": cty.NumberIntVal(7), + }), + }), + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got := tc.defaults.Apply(tc.value) + if !cmp.Equal(tc.want, got, valueComparer) { + t.Errorf("wrong result\n%s", cmp.Diff(tc.want, got, valueComparer)) + } + }) + } +} diff --git a/ext/typeexpr/get_type.go b/ext/typeexpr/get_type.go index 11b06897..98a861e9 100644 --- a/ext/typeexpr/get_type.go +++ b/ext/typeexpr/get_type.go @@ -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).", @@ -77,13 +80,20 @@ func getType(expr hcl.Expression, constraint bool) (cty.Type, hcl.Diagnostics) { } switch call.Name { - case "bool", "string", "number", "any": - return cty.DynamicPseudoType, hcl.Diagnostics{{ + case "bool", "string", "number": + 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, nil, hcl.Diagnostics{{ + Severity: hcl.DiagError, + Summary: invalidTypeSummary, + Detail: fmt.Sprintf("Type constraint keyword %q does not expect arguments.", call.Name), + Subject: &call.ArgsRange, + }} } if len(call.Arguments) != 1 { @@ -98,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), @@ -106,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.", @@ -114,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.", @@ -127,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.", @@ -148,6 +161,9 @@ 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) if attrName == "" { @@ -160,15 +176,102 @@ func getType(expr hcl.Expression, constraint bool) (cty.Type, hcl.Diagnostics) { }) continue } - aty, attrDiags := getType(attrDef.Value, constraint) + atyExpr := attrDef.Value + + // the attribute type expression might be wrapped in the special + // 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 { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: invalidTypeSummary, + Detail: "Optional attribute modifier requires the attribute type as its argument.", + Subject: call.ArgsRange.Ptr(), + Context: atyExpr.Range().Ptr(), + }) + continue + } + if constraint { + 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(), + }) + } + } + } else { + diags = append(diags, &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: invalidTypeSummary, + Detail: "Optional attribute modifier is only for type constraints, not for exact types.", + Subject: call.NameRange.Ptr(), + Context: atyExpr.Range().Ptr(), + }) + } + atyExpr = call.Arguments[0] + } + } + + 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 + } } - return cty.Object(atys), 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.", @@ -177,16 +280,28 @@ 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, 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), + Subject: call.NameRange.Ptr(), + }} 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), @@ -194,3 +309,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 +} diff --git a/ext/typeexpr/get_type_test.go b/ext/typeexpr/get_type_test.go index 391bf4f9..2dca23d2 100644 --- a/ext/typeexpr/get_type_test.go +++ b/ext/typeexpr/get_type_test.go @@ -1,16 +1,22 @@ package typeexpr import ( + "fmt" "testing" "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 @@ -103,13 +109,13 @@ func TestGetType(t *testing.T) { `any()`, false, cty.DynamicPseudoType, - `Primitive type keyword "any" does not expect arguments.`, + `Type constraint keyword "any" does not expect arguments.`, }, { `any()`, true, cty.DynamicPseudoType, - `Primitive type keyword "any" does not expect arguments.`, + `Type constraint keyword "any" does not expect arguments.`, }, { `list(string)`, @@ -245,16 +251,83 @@ func TestGetType(t *testing.T) { cty.List(cty.Map(cty.EmptyTuple)), ``, }, + + // Optional modifier + { + `object({name=string,age=optional(number)})`, + true, + cty.ObjectWithOptionalAttrs(map[string]cty.Type{ + "name": cty.String, + "age": cty.Number, + }, []string{"age"}), + ``, + }, + { + `object({name=string,meta=optional(any)})`, + true, + cty.ObjectWithOptionalAttrs(map[string]cty.Type{ + "name": cty.String, + "meta": cty.DynamicPseudoType, + }, []string{"meta"}), + ``, + }, + { + `object({name=string,age=optional(number)})`, + false, + cty.Object(map[string]cty.Type{ + "name": cty.String, + "age": cty.Number, + }), + `Optional attribute modifier is only for type constraints, not for exact types.`, + }, + { + `object({name=string,meta=optional(any)})`, + false, + cty.Object(map[string]cty.Type{ + "name": cty.String, + "meta": cty.DynamicPseudoType, + }), + `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, + cty.DynamicPseudoType, + `Keyword "optional" is valid only as a modifier for object type attributes.`, + }, + { + `optional`, + false, + cty.DynamicPseudoType, + `The keyword "optional" is not a valid type specification.`, + }, } for _, test := range tests { - t.Run(test.Source, func(t *testing.T) { + t.Run(fmt.Sprintf("%s (constraint=%v)", test.Source, test.Constraint), 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, test.Constraint) + got, _, diags := getType(expr, test.Constraint, false) if test.WantError == "" { for _, diag := range diags { t.Error(diag) @@ -326,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) @@ -350,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)) + } + }) + } +} diff --git a/ext/typeexpr/public.go b/ext/typeexpr/public.go index 3b8f618f..82f215c0 100644 --- a/ext/typeexpr/public.go +++ b/ext/typeexpr/public.go @@ -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