From b85bb09fb46f828b60562a12237b6e4d75d3d3f5 Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Thu, 17 Jan 2019 09:11:48 -0800 Subject: [PATCH] lang/funcs: Type conversion functions It's not normally necessary to make explicit type conversions in Terraform because the language implicitly converts as necessary, but explicit conversions are useful in a few specialized cases: - When defining output values for a reusable module, it may be desirable to force a "cleaner" output type than would naturally arise from a computation, such as forcing a string containing digits into a number. - Our 0.12upgrade mechanism will use some of these to replace use of the undocumented, hidden type conversion functions in HIL, and force particular type interpretations in some tricky cases. - We've found that type conversion functions can be useful as _temporary_ workarounds for bugs in Terraform and in providers where implicit type conversion isn't working correctly or a type constraint isn't specified precisely enough for the automatic conversion behavior. These all follow the same convention of being named "to" followed by a short type name. Since we've had a long-standing convention of running all the words together in lowercase in function names, we stick to that here even though some of these names are quite strange, because these should be rarely-used functions anyway. --- lang/funcs/conversion.go | 87 ++++++++++++ lang/funcs/conversion_test.go | 131 ++++++++++++++++++ lang/functions.go | 6 + .../docs/configuration/functions/list.html.md | 4 + .../docs/configuration/functions/map.html.md | 4 + .../configuration/functions/tobool.html.md | 37 +++++ .../configuration/functions/tolist.html.md | 42 ++++++ .../configuration/functions/tomap.html.md | 36 +++++ .../configuration/functions/tonumber.html.md | 32 +++++ .../configuration/functions/toset.html.md | 53 +++++++ .../configuration/functions/tostring.html.md | 33 +++++ website/layouts/functions.erb | 31 +++++ 12 files changed, 496 insertions(+) create mode 100644 lang/funcs/conversion.go create mode 100644 lang/funcs/conversion_test.go create mode 100644 website/docs/configuration/functions/tobool.html.md create mode 100644 website/docs/configuration/functions/tolist.html.md create mode 100644 website/docs/configuration/functions/tomap.html.md create mode 100644 website/docs/configuration/functions/tonumber.html.md create mode 100644 website/docs/configuration/functions/toset.html.md create mode 100644 website/docs/configuration/functions/tostring.html.md diff --git a/lang/funcs/conversion.go b/lang/funcs/conversion.go new file mode 100644 index 000000000000..83f859797220 --- /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 000000000000..ca0ac4651831 --- /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 d133d3906ea0..a7f654513a04 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 65117b027bb7..9a132d1dc4e6 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 cd6387e74597..efad30cd71d0 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 000000000000..2b2a023354ce --- /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 000000000000..8e455ec8c4f1 --- /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 000000000000..623cf3e57c8b --- /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 000000000000..58a44e2bcf8c --- /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 000000000000..7f73ebd95ca2 --- /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 000000000000..2820a2e382a7 --- /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 9c77cf089d4a..a1f06051d23b 100644 --- a/website/layouts/functions.erb +++ b/website/layouts/functions.erb @@ -357,6 +357,37 @@ + > + Type Conversion Functions + + + <% end %>