diff --git a/lang/funcs/conversion.go b/lang/funcs/conversion.go new file mode 100644 index 0000000000..83f8597972 --- /dev/null +++ b/lang/funcs/conversion.go @@ -0,0 +1,87 @@ +package funcs + +import ( + "strconv" + + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/convert" + "github.com/zclconf/go-cty/cty/function" +) + +// MakeToFunc constructs a "to..." function, like "tostring", which converts +// its argument to a specific type or type kind. +// +// The given type wantTy can be any type constraint that cty's "convert" package +// would accept. In particular, this means that you can pass +// cty.List(cty.DynamicPseudoType) to mean "list of any single type", which +// will then cause cty to attempt to unify all of the element types when given +// a tuple. +func MakeToFunc(wantTy cty.Type) function.Function { + return function.New(&function.Spec{ + Params: []function.Parameter{ + { + Name: "v", + // We use DynamicPseudoType rather than wantTy here so that + // all values will pass through the function API verbatim and + // we can handle the conversion logic within the Type and + // Impl functions. This allows us to customize the error + // messages to be more appropriate for an explicit type + // conversion, whereas the cty function system produces + // messages aimed at _implicit_ type conversions. + Type: cty.DynamicPseudoType, + AllowNull: true, + }, + }, + Type: func(args []cty.Value) (cty.Type, error) { + gotTy := args[0].Type() + if gotTy.Equals(wantTy) { + return wantTy, nil + } + conv := convert.GetConversionUnsafe(args[0].Type(), wantTy) + if conv == nil { + // We'll use some specialized errors for some trickier cases, + // but most we can handle in a simple way. + switch { + case gotTy.IsTupleType() && wantTy.IsTupleType(): + return cty.NilType, function.NewArgErrorf(0, "incompatible tuple type for conversion: %s", convert.MismatchMessage(gotTy, wantTy)) + case gotTy.IsObjectType() && wantTy.IsObjectType(): + return cty.NilType, function.NewArgErrorf(0, "incompatible object type for conversion: %s", convert.MismatchMessage(gotTy, wantTy)) + default: + return cty.NilType, function.NewArgErrorf(0, "cannot convert %s to %s", gotTy.FriendlyName(), wantTy.FriendlyNameForConstraint()) + } + } + // If a conversion is available then everything is fine. + return wantTy, nil + }, + Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { + // We didn't set "AllowUnknown" on our argument, so it is guaranteed + // to be known here but may still be null. + ret, err := convert.Convert(args[0], retType) + if err != nil { + // Because we used GetConversionUnsafe above, conversion can + // still potentially fail in here. For example, if the user + // asks to convert the string "a" to bool then we'll + // optimistically permit it during type checking but fail here + // once we note that the value isn't either "true" or "false". + gotTy := args[0].Type() + switch { + case gotTy == cty.String && wantTy == cty.Bool: + what := "string" + if !args[0].IsNull() { + what = strconv.Quote(args[0].AsString()) + } + return cty.NilVal, function.NewArgErrorf(0, `cannot convert %s to bool; only the strings "true" or "false" are allowed`, what) + case gotTy == cty.String && wantTy == cty.Number: + what := "string" + if !args[0].IsNull() { + what = strconv.Quote(args[0].AsString()) + } + return cty.NilVal, function.NewArgErrorf(0, `cannot convert %s to number; given string must be a decimal representation of a number`, what) + default: + return cty.NilVal, function.NewArgErrorf(0, "cannot convert %s to %s", gotTy.FriendlyName(), wantTy.FriendlyNameForConstraint()) + } + } + return ret, nil + }, + }) +} diff --git a/lang/funcs/conversion_test.go b/lang/funcs/conversion_test.go new file mode 100644 index 0000000000..ca0ac46518 --- /dev/null +++ b/lang/funcs/conversion_test.go @@ -0,0 +1,131 @@ +package funcs + +import ( + "fmt" + "testing" + + "github.com/zclconf/go-cty/cty" +) + +func TestTo(t *testing.T) { + tests := []struct { + Value cty.Value + TargetTy cty.Type + Want cty.Value + Err string + }{ + { + cty.StringVal("a"), + cty.String, + cty.StringVal("a"), + ``, + }, + { + cty.UnknownVal(cty.String), + cty.String, + cty.UnknownVal(cty.String), + ``, + }, + { + cty.NullVal(cty.String), + cty.String, + cty.NullVal(cty.String), + ``, + }, + { + cty.True, + cty.String, + cty.StringVal("true"), + ``, + }, + { + cty.StringVal("a"), + cty.Bool, + cty.DynamicVal, + `cannot convert "a" to bool; only the strings "true" or "false" are allowed`, + }, + { + cty.StringVal("a"), + cty.Number, + cty.DynamicVal, + `cannot convert "a" to number; given string must be a decimal representation of a number`, + }, + { + cty.NullVal(cty.String), + cty.Number, + cty.NullVal(cty.Number), + ``, + }, + { + cty.UnknownVal(cty.Bool), + cty.String, + cty.UnknownVal(cty.String), + ``, + }, + { + cty.UnknownVal(cty.String), + cty.Bool, + cty.UnknownVal(cty.Bool), // conversion is optimistic + ``, + }, + { + cty.TupleVal([]cty.Value{cty.StringVal("hello"), cty.True}), + cty.List(cty.String), + cty.ListVal([]cty.Value{cty.StringVal("hello"), cty.StringVal("true")}), + ``, + }, + { + cty.TupleVal([]cty.Value{cty.StringVal("hello"), cty.True}), + cty.Set(cty.String), + cty.SetVal([]cty.Value{cty.StringVal("hello"), cty.StringVal("true")}), + ``, + }, + { + cty.ObjectVal(map[string]cty.Value{"foo": cty.StringVal("hello"), "bar": cty.True}), + cty.Map(cty.String), + cty.MapVal(map[string]cty.Value{"foo": cty.StringVal("hello"), "bar": cty.StringVal("true")}), + ``, + }, + { + cty.EmptyTupleVal, + cty.String, + cty.DynamicVal, + `cannot convert tuple to string`, + }, + { + cty.UnknownVal(cty.EmptyTuple), + cty.String, + cty.DynamicVal, + `cannot convert tuple to string`, + }, + { + cty.EmptyObjectVal, + cty.Object(map[string]cty.Type{"foo": cty.String}), + cty.DynamicVal, + `incompatible object type for conversion: attribute "foo" is required`, + }, + } + + for _, test := range tests { + t.Run(fmt.Sprintf("to %s(%#v)", test.TargetTy.FriendlyNameForConstraint(), test.Value), func(t *testing.T) { + f := MakeToFunc(test.TargetTy) + got, err := f.Call([]cty.Value{test.Value}) + + if test.Err != "" { + if err == nil { + t.Fatal("succeeded; want error") + } + if got, want := err.Error(), test.Err; got != want { + t.Fatalf("wrong error\ngot: %s\nwant: %s", got, want) + } + return + } else if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if !got.RawEquals(test.Want) { + t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, test.Want) + } + }) + } +} diff --git a/lang/functions.go b/lang/functions.go index d133d3906e..a7f654513a 100644 --- a/lang/functions.go +++ b/lang/functions.go @@ -96,6 +96,12 @@ func (s *Scope) Functions() map[string]function.Function { "timestamp": funcs.TimestampFunc, "timeadd": funcs.TimeAddFunc, "title": funcs.TitleFunc, + "tostring": funcs.MakeToFunc(cty.String), + "tonumber": funcs.MakeToFunc(cty.Number), + "tobool": funcs.MakeToFunc(cty.Bool), + "toset": funcs.MakeToFunc(cty.Set(cty.DynamicPseudoType)), + "tolist": funcs.MakeToFunc(cty.List(cty.DynamicPseudoType)), + "tomap": funcs.MakeToFunc(cty.Map(cty.DynamicPseudoType)), "transpose": funcs.TransposeFunc, "trimspace": funcs.TrimSpaceFunc, "upper": stdlib.UpperFunc, diff --git a/website/docs/configuration/functions/list.html.md b/website/docs/configuration/functions/list.html.md index 65117b027b..9a132d1dc4 100644 --- a/website/docs/configuration/functions/list.html.md +++ b/website/docs/configuration/functions/list.html.md @@ -38,3 +38,7 @@ built-in list construction syntax, which achieves the same result: "c", ] ``` + +## Related Functions + +* [`tolist`](./tolist.html) converts a set value to a list. diff --git a/website/docs/configuration/functions/map.html.md b/website/docs/configuration/functions/map.html.md index cd6387e745..efad30cd71 100644 --- a/website/docs/configuration/functions/map.html.md +++ b/website/docs/configuration/functions/map.html.md @@ -36,3 +36,7 @@ built-in map construction syntax, which achieves the same result: "c" = "d" ] ``` + +## Related Functions + +* [`tomap`](./tomap.html) performs a type conversion to a map type. diff --git a/website/docs/configuration/functions/tobool.html.md b/website/docs/configuration/functions/tobool.html.md new file mode 100644 index 0000000000..2b2a023354 --- /dev/null +++ b/website/docs/configuration/functions/tobool.html.md @@ -0,0 +1,37 @@ +--- +layout: "functions" +page_title: "tobool - Functions - Configuration Language" +sidebar_current: "docs-funcs-conversion-tobool" +description: |- + The tobool function converts a value to boolean. +--- + +# `tobool` Function + +`tobool` converts its argument to a boolean value. + +Explicit type conversions are rarely necessary in Terraform because it will +convert types automatically where required. Use the explicit type conversion +functions only to normalize types returned in module outputs. + +Only boolean values and the exact strings `"true"` and `"false"` can be +converted to boolean. All other values will produce an error. + +## Examples + +``` +> tobool(true) +true +> tobool("true") +true +> tobool("no") +Error: Invalid function argument + +Invalid value for "v" parameter: cannot convert "no" to bool: only the strings +"true" or "false" are allowed. + +> tobool(1) +Error: Invalid function argument + +Invalid value for "v" parameter: cannot convert number to bool. +``` diff --git a/website/docs/configuration/functions/tolist.html.md b/website/docs/configuration/functions/tolist.html.md new file mode 100644 index 0000000000..8e455ec8c4 --- /dev/null +++ b/website/docs/configuration/functions/tolist.html.md @@ -0,0 +1,42 @@ +--- +layout: "functions" +page_title: "tolist - Functions - Configuration Language" +sidebar_current: "docs-funcs-conversion-tolist" +description: |- + The tolist function converts a value to a list. +--- + +# `tolist` Function + +`tolist` converts its argument to a list value. + +Explicit type conversions are rarely necessary in Terraform because it will +convert types automatically where required. Use the explicit type conversion +functions only to normalize types returned in module outputs. + +Pass a _set_ value to `tolist` to convert it to a list. Since set elements are +not ordered, the resulting list will have an undefined order that will be +consistent within a particular run of Terraform. + +## Examples + +``` +> tolist(["a", "b", "c"]) +[ + "a", + "b", + "c", +] +``` + +Since Terraform's concept of a list requires all of the elements to be of the +same type, mixed-typed elements will be converted to the most general type: + +``` +> tolist(["a", "b", 3]) +[ + "a", + "b", + "3", +] +``` diff --git a/website/docs/configuration/functions/tomap.html.md b/website/docs/configuration/functions/tomap.html.md new file mode 100644 index 0000000000..623cf3e57c --- /dev/null +++ b/website/docs/configuration/functions/tomap.html.md @@ -0,0 +1,36 @@ +--- +layout: "functions" +page_title: "tomap - Functions - Configuration Language" +sidebar_current: "docs-funcs-conversion-tomap" +description: |- + The tomap function converts a value to a map. +--- + +# `tomap` Function + +`tomap` converts its argument to a map value. + +Explicit type conversions are rarely necessary in Terraform because it will +convert types automatically where required. Use the explicit type conversion +functions only to normalize types returned in module outputs. + +## Examples + +``` +> tomap({"a" = 1, "b" = 2}) +{ + "a" = 1 + "b" = 2 +} +``` + +Since Terraform's concept of a map requires all of the elements to be of the +same type, mixed-typed elements will be converted to the most general type: + +``` +> tomap({"a" = "foo", "b" = true}) +{ + "a" = "foo" + "b" = "true" +} +``` diff --git a/website/docs/configuration/functions/tonumber.html.md b/website/docs/configuration/functions/tonumber.html.md new file mode 100644 index 0000000000..58a44e2bcf --- /dev/null +++ b/website/docs/configuration/functions/tonumber.html.md @@ -0,0 +1,32 @@ +--- +layout: "functions" +page_title: "tonumber - Functions - Configuration Language" +sidebar_current: "docs-funcs-conversion-tonumber" +description: |- + The tonumber function converts a value to a number. +--- + +# `tonumber` Function + +`tonumber` converts its argument to a number value. + +Explicit type conversions are rarely necessary in Terraform because it will +convert types automatically where required. Use the explicit type conversion +functions only to normalize types returned in module outputs. + +Only numbers and strings containing decimal representations of numbers can be +converted to number. All other values will produce an error. + +## Examples + +``` +> tonumber(1) +1 +> tonumber("1") +1 +> tonumber("no") +Error: Invalid function argument + +Invalid value for "v" parameter: cannot convert "no" to number: string must be +a decimal representation of a number. +``` diff --git a/website/docs/configuration/functions/toset.html.md b/website/docs/configuration/functions/toset.html.md new file mode 100644 index 0000000000..7f73ebd95c --- /dev/null +++ b/website/docs/configuration/functions/toset.html.md @@ -0,0 +1,53 @@ +--- +layout: "functions" +page_title: "toset - Functions - Configuration Language" +sidebar_current: "docs-funcs-conversion-toset" +description: |- + The toset function converts a value to a set. +--- + +# `toset` Function + +`toset` converts its argument to a set value. + +Explicit type conversions are rarely necessary in Terraform because it will +convert types automatically where required. Use the explicit type conversion +functions only to normalize types returned in module outputs. + +Pass a _list_ value to `toset` to convert it to a set, which will remove any +duplicate elements and discard the ordering of the elements. + +## Examples + +``` +> toset(["a", "b", "c"]) +[ + "a", + "b", + "c", +] +``` + +Since Terraform's concept of a set requires all of the elements to be of the +same type, mixed-typed elements will be converted to the most general type: + +``` +> tolist(["a", "b", 3]) +[ + "a", + "b", + "3", +] +``` + +Set collections are unordered and cannot contain duplicate values, so the +ordering of the argument elements is lost and any duplicate values are +coalesced: + +``` +> tolist(["c", "b", "b"]) +[ + "b", + "c", +] +``` diff --git a/website/docs/configuration/functions/tostring.html.md b/website/docs/configuration/functions/tostring.html.md new file mode 100644 index 0000000000..2820a2e382 --- /dev/null +++ b/website/docs/configuration/functions/tostring.html.md @@ -0,0 +1,33 @@ +--- +layout: "functions" +page_title: "tostring - Functions - Configuration Language" +sidebar_current: "docs-funcs-conversion-tostring" +description: |- + The tostring function converts a value to a string. +--- + +# `tostring` Function + +`tostring` converts its argument to a string value. + +Explicit type conversions are rarely necessary in Terraform because it will +convert types automatically where required. Use the explicit type conversion +functions only to normalize types returned in module outputs. + +Only the primitive types (string, number, and bool) can be converted to string. +All other values will produce an error. + +## Examples + +``` +> tostring("hello") +hello +> tostring(1) +1 +> tostring(true) +true +> tostring([]) +Error: Invalid function argument + +Invalid value for "v" parameter: cannot convert tuple to string. +``` diff --git a/website/layouts/functions.erb b/website/layouts/functions.erb index 9c77cf089d..a1f06051d2 100644 --- a/website/layouts/functions.erb +++ b/website/layouts/functions.erb @@ -357,6 +357,37 @@ + > + Type Conversion Functions + + + <% end %>