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.
This commit is contained in:
Martin Atkins 2019-01-17 09:11:48 -08:00
parent ba6e243bd9
commit 2f8f7d6f4d
12 changed files with 496 additions and 0 deletions

87
lang/funcs/conversion.go Normal file
View File

@ -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
},
})
}

View File

@ -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)
}
})
}
}

View File

@ -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,

View File

@ -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.

View File

@ -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.

View File

@ -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.
```

View File

@ -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",
]
```

View File

@ -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"
}
```

View File

@ -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.
```

View File

@ -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",
]
```

View File

@ -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.
```

View File

@ -357,6 +357,37 @@
</ul>
</li>
<li<%= sidebar_current("docs-funcs-conversion") %>>
<a href="#docs-funcs-conversion">Type Conversion Functions</a>
<ul class="nav nav-visible" id="docs-funcs-conversion">
<li<%= sidebar_current("docs-funcs-conversion-tobool") %>>
<a href="/docs/configuration/functions/tobool.html">tobool</a>
</li>
<li<%= sidebar_current("docs-funcs-conversion-tolist") %>>
<a href="/docs/configuration/functions/tolist.html">tolist</a>
</li>
<li<%= sidebar_current("docs-funcs-conversion-tomap") %>>
<a href="/docs/configuration/functions/tomap.html">tomap</a>
</li>
<li<%= sidebar_current("docs-funcs-conversion-tonumber") %>>
<a href="/docs/configuration/functions/tonumber.html">tonumber</a>
</li>
<li<%= sidebar_current("docs-funcs-conversion-toset") %>>
<a href="/docs/configuration/functions/toset.html">toset</a>
</li>
<li<%= sidebar_current("docs-funcs-conversion-tostring") %>>
<a href="/docs/configuration/functions/tostring.html">tostring</a>
</li>
</ul>
</li>
</ul>
</div>
<% end %>