Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
apparentlymart committed Jan 17, 2019
1 parent 2ad9f05 commit b85bb09
Show file tree
Hide file tree
Showing 12 changed files with 496 additions and 0 deletions.
87 changes: 87 additions & 0 deletions 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
},
})
}
131 changes: 131 additions & 0 deletions 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)
}
})
}
}
6 changes: 6 additions & 0 deletions lang/functions.go
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions website/docs/configuration/functions/list.html.md
Expand Up @@ -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.
4 changes: 4 additions & 0 deletions website/docs/configuration/functions/map.html.md
Expand Up @@ -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.
37 changes: 37 additions & 0 deletions 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.
```
42 changes: 42 additions & 0 deletions 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",
]
```
36 changes: 36 additions & 0 deletions 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"
}
```
32 changes: 32 additions & 0 deletions 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.
```

0 comments on commit b85bb09

Please sign in to comment.