From 140c613ae8cae547b4b0c6d43a865fbbdae8d779 Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Fri, 8 Jan 2021 16:02:56 -0800 Subject: [PATCH] lang/funcs: "one" function In the Terraform language we typically use lists of zero or one values in some sense interchangably with single values that might be null, because various Terraform language constructs are designed to work with collections rather than with nullable values. In Terraform v0.12 we made the splat operator [*] have a "special power" of concisely converting from a possibly-null single value into a zero-or-one list as a way to make that common operation more concise. In a sense this "one" function is the opposite operation to that special power: it goes from a zero-or-one collection (list, set, or tuple) to a possibly-null single value. This is a concise alternative to the following clunky conditional expression, with the additional benefit that the following expression is also not viable for set values, and it also properly handles the case where there's unexpectedly more than one value: length(var.foo) != 0 ? var.foo[0] : null Instead, we can write: one(var.foo) As with the splat operator, this is a tricky tradeoff because it could be argued that it's not something that'd be immediately intuitive to someone unfamiliar with Terraform. However, I think that's justified given how often zero-or-one collections arise in typical Terraform configurations. Unlike the splat operator, it should at least be easier to search for its name and find its documentation the first time you see it in a configuration. My expectation that this will become a common pattern is also my justification for giving it a short, concise name. Arguably it could be better named something like "oneornull", but that's a pretty clunky name and I'm not convinced it really adds any clarity for someone who isn't already familiar with it. --- lang/funcs/collection.go | 84 ++++++ lang/funcs/collection_test.go | 281 ++++++++++++++++++++ lang/functions.go | 1 + lang/functions_test.go | 11 + website/docs/language/functions/one.html.md | 121 +++++++++ website/layouts/language.erb | 4 + 6 files changed, 502 insertions(+) create mode 100644 website/docs/language/functions/one.html.md 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