diff --git a/lang/funcs/collection.go b/lang/funcs/collection.go index 33073d747..ce6c8dc8f 100644 --- a/lang/funcs/collection.go +++ b/lang/funcs/collection.go @@ -10,6 +10,7 @@ import ( "github.com/zclconf/go-cty/cty/convert" "github.com/zclconf/go-cty/cty/function" "github.com/zclconf/go-cty/cty/function/stdlib" + "github.com/zclconf/go-cty/cty/gocty" ) var LengthFunc = function.New(&function.Spec{ @@ -381,6 +382,83 @@ var MatchkeysFunc = function.New(&function.Spec{ }, }) +// OneFunc returns either the first element of a one-element list, or null +// if given a zero-element list. +var OneFunc = function.New(&function.Spec{ + Params: []function.Parameter{ + { + Name: "list", + Type: cty.DynamicPseudoType, + }, + }, + Type: func(args []cty.Value) (cty.Type, error) { + ty := args[0].Type() + switch { + case ty.IsListType() || ty.IsSetType(): + return ty.ElementType(), nil + case ty.IsTupleType(): + etys := ty.TupleElementTypes() + switch len(etys) { + case 0: + // No specific type information, so we'll ultimately return + // a null value of unknown type. + return cty.DynamicPseudoType, nil + case 1: + return etys[0], nil + } + } + return cty.NilType, function.NewArgErrorf(0, "must be a list, set, or tuple value with either zero or one elements") + }, + Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) { + val := args[0] + ty := val.Type() + + // Our parameter spec above doesn't set AllowUnknown or AllowNull, + // so we can assume our top-level collection is both known and non-null + // in here. + + switch { + case ty.IsListType() || ty.IsSetType(): + lenVal := val.Length() + if !lenVal.IsKnown() { + return cty.UnknownVal(retType), nil + } + var l int + err := gocty.FromCtyValue(lenVal, &l) + if err != nil { + // It would be very strange to get here, because that would + // suggest that the length is either not a number or isn't + // an integer, which would suggest a bug in cty. + return cty.NilVal, fmt.Errorf("invalid collection length: %s", err) + } + switch l { + case 0: + return cty.NullVal(retType), nil + case 1: + var ret cty.Value + // We'll use an iterator here because that works for both lists + // and sets, whereas indexing directly would only work for lists. + // Since we've just checked the length, we should only actually + // run this loop body once. + for it := val.ElementIterator(); it.Next(); { + _, ret = it.Element() + } + return ret, nil + } + case ty.IsTupleType(): + etys := ty.TupleElementTypes() + switch len(etys) { + case 0: + return cty.NullVal(retType), nil + case 1: + ret := val.Index(cty.NumberIntVal(0)) + return ret, nil + } + } + return cty.NilVal, function.NewArgErrorf(0, "must be a list, set, or tuple value with either zero or one elements") + }, +}) + // SumFunc constructs a function that returns the sum of all // numbers provided in a list var SumFunc = function.New(&function.Spec{ @@ -595,6 +673,12 @@ func Matchkeys(values, keys, searchset cty.Value) (cty.Value, error) { return MatchkeysFunc.Call([]cty.Value{values, keys, searchset}) } +// One returns either the first element of a one-element list, or null +// if given a zero-element list.. +func One(list cty.Value) (cty.Value, error) { + return OneFunc.Call([]cty.Value{list}) +} + // Sum adds numbers in a list, set, or tuple func Sum(list cty.Value) (cty.Value, error) { return SumFunc.Call([]cty.Value{list}) diff --git a/lang/funcs/collection_test.go b/lang/funcs/collection_test.go index 0b61738ac..ad53cbb6c 100644 --- a/lang/funcs/collection_test.go +++ b/lang/funcs/collection_test.go @@ -993,6 +993,287 @@ func TestMatchkeys(t *testing.T) { } } +func TestOne(t *testing.T) { + tests := []struct { + List cty.Value + Want cty.Value + Err string + }{ + { + cty.ListVal([]cty.Value{ + cty.NumberIntVal(1), + }), + cty.NumberIntVal(1), + "", + }, + { + cty.ListValEmpty(cty.Number), + cty.NullVal(cty.Number), + "", + }, + { + cty.ListVal([]cty.Value{ + cty.NumberIntVal(1), + cty.NumberIntVal(2), + cty.NumberIntVal(3), + }), + cty.NilVal, + "must be a list, set, or tuple value with either zero or one elements", + }, + { + cty.ListVal([]cty.Value{ + cty.UnknownVal(cty.Number), + }), + cty.UnknownVal(cty.Number), + "", + }, + { + cty.ListVal([]cty.Value{ + cty.UnknownVal(cty.Number), + cty.UnknownVal(cty.Number), + }), + cty.NilVal, + "must be a list, set, or tuple value with either zero or one elements", + }, + { + cty.UnknownVal(cty.List(cty.String)), + cty.UnknownVal(cty.String), + "", + }, + { + cty.NullVal(cty.List(cty.String)), + cty.NilVal, + "argument must not be null", + }, + { + cty.ListVal([]cty.Value{ + cty.NumberIntVal(1), + }).Mark("boop"), + cty.NumberIntVal(1).Mark("boop"), + "", + }, + { + cty.ListValEmpty(cty.Bool).Mark("boop"), + cty.NullVal(cty.Bool).Mark("boop"), + "", + }, + { + cty.ListVal([]cty.Value{ + cty.NumberIntVal(1).Mark("boop"), + }), + cty.NumberIntVal(1).Mark("boop"), + "", + }, + + { + cty.SetVal([]cty.Value{ + cty.NumberIntVal(1), + }), + cty.NumberIntVal(1), + "", + }, + { + cty.SetValEmpty(cty.Number), + cty.NullVal(cty.Number), + "", + }, + { + cty.SetVal([]cty.Value{ + cty.NumberIntVal(1), + cty.NumberIntVal(2), + cty.NumberIntVal(3), + }), + cty.NilVal, + "must be a list, set, or tuple value with either zero or one elements", + }, + { + cty.SetVal([]cty.Value{ + cty.UnknownVal(cty.Number), + }), + cty.UnknownVal(cty.Number), + "", + }, + { + cty.SetVal([]cty.Value{ + cty.UnknownVal(cty.Number), + cty.UnknownVal(cty.Number), + }), + // The above would be valid if those two unknown values were + // equal known values, so this returns unknown rather than failing. + cty.UnknownVal(cty.Number), + "", + }, + { + cty.UnknownVal(cty.Set(cty.String)), + cty.UnknownVal(cty.String), + "", + }, + { + cty.NullVal(cty.Set(cty.String)), + cty.NilVal, + "argument must not be null", + }, + { + cty.SetVal([]cty.Value{ + cty.NumberIntVal(1), + }).Mark("boop"), + cty.NumberIntVal(1).Mark("boop"), + "", + }, + { + cty.SetValEmpty(cty.Bool).Mark("boop"), + cty.NullVal(cty.Bool).Mark("boop"), + "", + }, + { + cty.SetVal([]cty.Value{ + cty.NumberIntVal(1).Mark("boop"), + }), + cty.NumberIntVal(1).Mark("boop"), + "", + }, + + { + cty.TupleVal([]cty.Value{ + cty.NumberIntVal(1), + }), + cty.NumberIntVal(1), + "", + }, + { + cty.EmptyTupleVal, + cty.NullVal(cty.DynamicPseudoType), + "", + }, + { + cty.TupleVal([]cty.Value{ + cty.NumberIntVal(1), + cty.NumberIntVal(2), + cty.NumberIntVal(3), + }), + cty.NilVal, + "must be a list, set, or tuple value with either zero or one elements", + }, + { + cty.TupleVal([]cty.Value{ + cty.UnknownVal(cty.Number), + }), + cty.UnknownVal(cty.Number), + "", + }, + { + cty.TupleVal([]cty.Value{ + cty.UnknownVal(cty.Number), + cty.UnknownVal(cty.Number), + }), + cty.NilVal, + "must be a list, set, or tuple value with either zero or one elements", + }, + { + cty.UnknownVal(cty.EmptyTuple), + // Could actually return null here, but don't for consistency with unknown lists + cty.UnknownVal(cty.DynamicPseudoType), + "", + }, + { + cty.UnknownVal(cty.Tuple([]cty.Type{cty.Bool})), + cty.UnknownVal(cty.Bool), + "", + }, + { + cty.UnknownVal(cty.Tuple([]cty.Type{cty.Bool, cty.Number})), + cty.NilVal, + "must be a list, set, or tuple value with either zero or one elements", + }, + { + cty.NullVal(cty.EmptyTuple), + cty.NilVal, + "argument must not be null", + }, + { + cty.NullVal(cty.Tuple([]cty.Type{cty.Bool})), + cty.NilVal, + "argument must not be null", + }, + { + cty.NullVal(cty.Tuple([]cty.Type{cty.Bool, cty.Number})), + cty.NilVal, + "argument must not be null", + }, + { + cty.TupleVal([]cty.Value{ + cty.NumberIntVal(1), + }).Mark("boop"), + cty.NumberIntVal(1).Mark("boop"), + "", + }, + { + cty.EmptyTupleVal.Mark("boop"), + cty.NullVal(cty.DynamicPseudoType).Mark("boop"), + "", + }, + { + cty.TupleVal([]cty.Value{ + cty.NumberIntVal(1).Mark("boop"), + }), + cty.NumberIntVal(1).Mark("boop"), + "", + }, + + { + cty.DynamicVal, + cty.DynamicVal, + "", + }, + { + cty.NullVal(cty.DynamicPseudoType), + cty.NilVal, + "argument must not be null", + }, + { + cty.MapValEmpty(cty.String), + cty.NilVal, + "must be a list, set, or tuple value with either zero or one elements", + }, + { + cty.EmptyObjectVal, + cty.NilVal, + "must be a list, set, or tuple value with either zero or one elements", + }, + { + cty.True, + cty.NilVal, + "must be a list, set, or tuple value with either zero or one elements", + }, + { + cty.UnknownVal(cty.Bool), + cty.NilVal, + "must be a list, set, or tuple value with either zero or one elements", + }, + } + + for _, test := range tests { + t.Run(fmt.Sprintf("one(%#v)", test.List), func(t *testing.T) { + got, err := One(test.List) + + if test.Err != "" { + if err == nil { + t.Fatal("succeeded; want error") + } else if got, want := err.Error(), test.Err; got != want { + t.Fatalf("wrong error\n got: %s\nwant: %s", got, want) + } + return + } else if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if !test.Want.RawEquals(got) { + t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, test.Want) + } + }) + } +} + func TestSum(t *testing.T) { tests := []struct { List cty.Value diff --git a/lang/functions.go b/lang/functions.go index 3365d42b4..aa182d17e 100644 --- a/lang/functions.go +++ b/lang/functions.go @@ -93,6 +93,7 @@ func (s *Scope) Functions() map[string]function.Function { "md5": funcs.Md5Func, "merge": stdlib.MergeFunc, "min": stdlib.MinFunc, + "one": funcs.OneFunc, "parseint": stdlib.ParseIntFunc, "pathexpand": funcs.PathExpandFunc, "pow": stdlib.PowFunc, diff --git a/lang/functions_test.go b/lang/functions_test.go index 7c9b069fb..c33bc3672 100644 --- a/lang/functions_test.go +++ b/lang/functions_test.go @@ -614,6 +614,17 @@ func TestFunctions(t *testing.T) { }, }, + "one": { + { + `one([])`, + cty.NullVal(cty.DynamicPseudoType), + }, + { + `one([true])`, + cty.True, + }, + }, + "parseint": { { `parseint("100", 10)`, diff --git a/website/docs/language/functions/one.html.md b/website/docs/language/functions/one.html.md new file mode 100644 index 000000000..0fc5918ff --- /dev/null +++ b/website/docs/language/functions/one.html.md @@ -0,0 +1,121 @@ +--- +layout: "language" +page_title: "one - Functions - Configuration Language" +sidebar_current: "docs-funcs-collection-one" +description: |- + The 'one' function transforms a list with either zero or one elements into + either a null value or the value of the first element. +--- + +# `one` Function + +-> **Note:** This function is available only in Terraform v0.15 and later. + +`one` takes a list, set, or tuple value with either zero or one elements. +If the collection is empty, `one` returns `null`. Otherwise, `one` returns +the first element. If there are two or more elements then `one` will return +an error. + +This is a specialized function intended for the common situation where a +conditional item is represented as either a zero- or one-element list, where +a module author wishes to return a single value that might be null instead. + +For example: + +```hcl +variable "include_ec2_instance" { + type = bool + default = true +} + +resource "aws_instance" "example" { + count = var.include_ec2_instance ? 1 : 0 + + # (other resource arguments...) +} + +output "instance_ip_address" { + value = one(aws_instance.example[*].private_ip) +} +``` + +Because the `aws_instance` resource above has the `count` argument set to a +conditional that returns either zero or one, the value of +`aws_instance.example` is a list of either zero or one elements. The +`instance_ip_address` output value uses the `one` function as a concise way +to return either the private IP address of a single instance, or `null` if +no instances were created. + +## Relationship to the "Splat" Operator + +The Terraform language has a built-in operator `[*]`, known as +[the _splat_ operator](../expressions/splat.html), and one if its functions +is to translate a primitive value that might be null into a list of either +zero or one elements: + +```hcl +variable "ec2_instance_type" { + description = "The type of instance to create. If set to null, no instance will be created." + + type = string + default = null +} + +resource "aws_instance" "example" { + count = length(var.ec2_instance_type[*]) + + instance_type = var.ec2_instance_type + # (other resource arguments...) +} + +output "instance_ip_address" { + value = one(aws_instance.example[*].private_ip) +} +``` + +In this case we can see that the `one` function is, in a sense, the opposite +of applying `[*]` to a primitive-typed value. Splat can convert a possibly-null +value into a zero-or-one list, and `one` can reverse that to return to a +primitive value that might be null. + +## Examples + +``` +> one([]) +null +> one(["hello"]) +"hello" +> one(["hello", "goodbye"]) + +Error: Invalid function argument + +Invalid value for "list" parameter: must be a list, set, or tuple value with +either zero or one elements. +``` + +### Using `one` with sets + +The `one` function can be particularly helpful in situations where you have a +set that you know has only zero or one elements. Set values don't support +indexing, so it's not valid to write `var.set[0]` to extract the "first" +element of a set, but if you know that there's only one item then `one` can +isolate and return that single item: + +``` +> one(toset([])) +null +> one(toset(["hello"])) +"hello" +``` + +Don't use `one` with sets that might have more than one element. This function +will fail in that case: + +``` +> one(toset(["hello","goodbye"])) + +Error: Invalid function argument + +Invalid value for "list" parameter: must be a list, set, or tuple value with +either zero or one elements. +``` diff --git a/website/layouts/language.erb b/website/layouts/language.erb index 2e0e12b5f..f7ac17820 100644 --- a/website/layouts/language.erb +++ b/website/layouts/language.erb @@ -523,6 +523,10 @@ merge +
  • + one +
  • +
  • range