Repository: uber-go/dig
Pull request #264 (2020-03-24 22:06:35)

Add support for flattening value group providers
Resolves https://github.com/uber-go/fx/issues/693.

codecov[bot]: # [Codecov](https://codecov.io/gh/uber-go/dig/pull/264?src=pr&el=h1) Report
> Merging [#264](https://codecov.io/gh/uber-go/dig/pull/264?src=pr&el=desc) into [master](https://codecov.io/gh/uber-go/dig/commit/c6d85c0d9161ebf4a412011a07fa26d0575cbdb3&el=desc) will **decrease** coverage by `0.26%`.
> The diff coverage is `90.24%`.

[![Impacted file tree graph](https://codecov.io/gh/uber-go/dig/pull/264/graphs/tree.svg?width=650&height=150&src=pr&token=ARuH7Frwgw)](https://codecov.io/gh/uber-go/dig/pull/264?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master     #264      +/-   ##
==========================================
- Coverage   98.38%   98.11%   -0.27%     
==========================================
  Files          12       12              
  Lines        1236     1275      +39     
==========================================
+ Hits         1216     1251      +35     
- Misses         16       18       +2     
- Partials        4        6       +2     
```


| [Impacted Files](https://codecov.io/gh/uber-go/dig/pull/264?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [param.go](https://codecov.io/gh/uber-go/dig/pull/264/diff?src=pr&el=tree#diff-cGFyYW0uZ28=) | `95.43% <71.42%> (-0.91%)` | :arrow_down: |
| [result.go](https://codecov.io/gh/uber-go/dig/pull/264/diff?src=pr&el=tree#diff-cmVzdWx0Lmdv) | `97.10% <87.50%> (-1.00%)` | :arrow_down: |
| [dig.go](https://codecov.io/gh/uber-go/dig/pull/264/diff?src=pr&el=tree#diff-ZGlnLmdv) | `100.00% <100.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/uber-go/dig/pull/264?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/uber-go/dig/pull/264?src=pr&el=footer). Last update [c6d85c0...7b2bc27](https://codecov.io/gh/uber-go/dig/pull/264?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8edd5f5..e70839c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
 ### Added
 - GraphViz visualization of the graph now includes names of packages next to
   constructors.
+- Added a `flatten` modifier to group tags for slices to allow providing
+  individual elements instead of the slice for a group value. See package
+  doucmentation for more information.
 
 ### Changed
 - Drop library dependency on `golang.org/x/lint`.
diff --git a/dig.go b/dig.go
index 6ba606e..996bef6 100644
--- a/dig.go
+++ b/dig.go
@@ -37,7 +37,6 @@ import (
 const (
 	_optionalTag = "optional"
 	_nameTag     = "name"
-	_groupTag    = "group"
 )
 
 // Unique identification of an object in the graph.
diff --git a/doc.go b/doc.go
index c3c20e8..260b4e5 100644
--- a/doc.go
+++ b/doc.go
@@ -331,4 +331,19 @@
 //
 // Note that values in a value group are unordered. Dig makes no guarantees
 // about the order in which these values will be produced.
+//
+// Value groups can be used to provide multiple values for a group from a
+// dig.Out using slices, however considering groups are retrieved by requesting
+// a slice this implies that the values must be retrieved using a slice of
+// slices. As of dig v1.9.0, if you want to provide individual elements to the
+// group instead of the slice itself, you can add the `flatten` modifier to the
+// group from a dig.Out.
+//
+//   type IntResult struct {
+//     dig.Out
+//
+//     Handler []int `group:"server"`         // [][]int from dig.In
+//     Handler []int `group:"server,flatten"` // []int from dig.In
+//   }
+//
 package dig // import "go.uber.org/dig"
diff --git a/group.go b/group.go
new file mode 100644
index 0000000..b967e06
--- /dev/null
+++ b/group.go
@@ -0,0 +1,55 @@
+// Copyright (c) 2020 Uber Technologies, Inc.
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+// THE SOFTWARE.
+
+package dig
+
+import (
+	"fmt"
+	"strings"
+)
+
+const (
+	_groupTag = "group"
+)
+
+type group struct {
+	Name    string
+	Flatten bool
+}
+
+type errInvalidGroupOption struct{ Option string }
+
+func (e errInvalidGroupOption) Error() string {
+	return fmt.Sprintf("invalid option %q", e.Option)
+}
+
+func parseGroupString(s string) (group, error) {
+	components := strings.Split(s, ",")
+	g := group{Name: components[0]}
+	for _, c := range components[1:] {
+		switch c {
+		case "flatten":
+			g.Flatten = true
+		default:
+			return g, errInvalidGroupOption{Option: c}
+		}
+	}
+	return g, nil
+}
diff --git a/param.go b/param.go
index 32933be..37b2343 100644
--- a/param.go
+++ b/param.go
@@ -414,7 +414,11 @@ func (pt paramGroupedSlice) DotParam() []*dot.Param {
 //
 // The type MUST be a slice type.
 func newParamGroupedSlice(f reflect.StructField) (paramGroupedSlice, error) {
-	pg := paramGroupedSlice{Group: f.Tag.Get(_groupTag), Type: f.Type}
+	g, err := parseGroupString(f.Tag.Get(_groupTag))
+	if err != nil {
+		return paramGroupedSlice{}, err
+	}
+	pg := paramGroupedSlice{Group: g.Name, Type: f.Type}
 
 	name := f.Tag.Get(_nameTag)
 	optional, _ := isFieldOptional(f)
@@ -422,7 +426,9 @@ func newParamGroupedSlice(f reflect.StructField) (paramGroupedSlice, error) {
 	case f.Type.Kind() != reflect.Slice:
 		return pg, errf("value groups may be consumed as slices only",
 			"field %q (%v) is not a slice", f.Name, f.Type)
-
+	case g.Flatten:
+		return pg, errf("cannot use flatten in parameter value groups",
+			"field %q (%v) specifies flatten", f.Name, f.Type)
 	case name != "":
 		return pg, errf(
 			"cannot use named values with value groups",
diff --git a/result.go b/result.go
index e6e1ec4..638e288 100644
--- a/result.go
+++ b/result.go
@@ -81,7 +81,21 @@ func newResult(t reflect.Type, opts resultOptions) (result, error) {
 			"cannot return a pointer to a result object, use a value instead",
 			"%v is a pointer to a struct that embeds dig.Out", t)
 	case len(opts.Group) > 0:
-		return resultGrouped{Type: t, Group: opts.Group}, nil
+		g, err := parseGroupString(opts.Group)
+		if err != nil {
+			return nil, errf(
+				"cannot parse group %q", opts.Group, err)
+		}
+		rg := resultGrouped{Type: t, Group: g.Name, Flatten: g.Flatten}
+		if g.Flatten {
+			if t.Kind() != reflect.Slice {
+				return nil, errf(
+					"flatten can be applied to slices only",
+					"%v is not a slice", t)
+			}
+			rg.Type = rg.Type.Elem()
+		}
+		return rg, nil
 	default:
 		return resultSingle{Type: t, Name: opts.Name}, nil
 	}
@@ -371,6 +385,11 @@ type resultGrouped struct {
 
 	// Type of value produced.
 	Type reflect.Type
+
+	// Indicates elements of a value are to be injected individually, instead of
+	// as a group. Requires the value's slice to be a group. If set, Type will be
+	// the type of individual elements rather than the group.
+	Flatten bool
 }
 
 func (rt resultGrouped) DotResult() []*dot.Result {
@@ -386,11 +405,21 @@ func (rt resultGrouped) DotResult() []*dot.Result {
 
 // newResultGrouped(f) builds a new resultGrouped from the provided field.
 func newResultGrouped(f reflect.StructField) (resultGrouped, error) {
-	rg := resultGrouped{Group: f.Tag.Get(_groupTag), Type: f.Type}
-
+	g, err := parseGroupString(f.Tag.Get(_groupTag))
+	if err != nil {
+		return resultGrouped{}, err
+	}
+	rg := resultGrouped{
+		Group:   g.Name,
+		Flatten: g.Flatten,
+		Type:    f.Type,
+	}
 	name := f.Tag.Get(_nameTag)
 	optional, _ := isFieldOptional(f)
 	switch {
+	case g.Flatten && f.Type.Kind() != reflect.Slice:
+		return rg, errf("flatten can be applied to slices only",
+			"field %q (%v) is not a slice", f.Name, f.Type)
 	case name != "":
 		return rg, errf(
 			"cannot use named values with value groups",
@@ -398,10 +427,19 @@ func newResultGrouped(f reflect.StructField) (resultGrouped, error) {
 	case optional:
 		return rg, errors.New("value groups cannot be optional")
 	}
+	if g.Flatten {
+		rg.Type = f.Type.Elem()
+	}
 
 	return rg, nil
 }
 
 func (rt resultGrouped) Extract(cw containerWriter, v reflect.Value) {
-	cw.submitGroupedValue(rt.Group, rt.Type, v)
+	if !rt.Flatten {
+		cw.submitGroupedValue(rt.Group, rt.Type, v)
+		return
+	}
+	for i := 0; i < v.Len(); i++ {
+		cw.submitGroupedValue(rt.Group, rt.Type, v.Index(i))
+	}
 }


Test patch:
diff --git a/dig_test.go b/dig_test.go
index 74ce401..0d8dc82 100644
--- a/dig_test.go
+++ b/dig_test.go
@@ -1217,6 +1217,58 @@ func TestGroups(t *testing.T) {
 		)
 		assert.Equal(t, gaveErr, RootCause(err))
 	})
+
+	t.Run("flatten collects slices", func(t *testing.T) {
+		c := New(setRand(rand.New(rand.NewSource(0))))
+
+		type out struct {
+			Out
+
+			Value []int `group:"val,flatten"`
+		}
+
+		provide := func(i []int) {
+			require.NoError(t, c.Provide(func() out {
+				return out{Value: i}
+			}), "failed to provide ")
+		}
+
+		provide([]int{1, 2})
+		provide([]int{3, 4})
+
+		type in struct {
+			In
+
+			Values []int `group:"val"`
+		}
+
+		require.NoError(t, c.Invoke(func(i in) {
+			assert.Equal(t, []int{2, 3, 4, 1}, i.Values)
+		}), "invoke failed")
+	})
+
+	t.Run("flatten via option", func(t *testing.T) {
+		c := New(setRand(rand.New(rand.NewSource(0))))
+		require.NoError(t, c.Provide(func() []int {
+			return []int{1, 2, 3}
+		}, Group("val,flatten")), "failed to provide ")
+		type in struct {
+			In
+
+			Values []int `group:"val"`
+		}
+
+		require.NoError(t, c.Invoke(func(i in) {
+			assert.Equal(t, []int{2, 3, 1}, i.Values)
+		}), "invoke failed")
+	})
+
+	t.Run("flatten via option error if not a slice", func(t *testing.T) {
+		c := New(setRand(rand.New(rand.NewSource(0))))
+		err := c.Provide(func() int { return 1 }, Group("val,flatten"))
+		require.Error(t, err, "failed to provide")
+		assert.Contains(t, err.Error(), "flatten can be applied to slices only")
+	})
 }
 
 // --- END OF END TO END TESTS
@@ -1453,6 +1505,12 @@ func TestProvideInvalidGroup(t *testing.T) {
 	}, Group("foo`bar"))
 	require.Error(t, err, "Provide must fail")
 	assert.Contains(t, err.Error(), "invalid dig.Group(\"foo`bar\"): group names cannot contain backquotes")
+
+	err = c.Provide(func() io.Reader {
+		panic("this function must not be called")
+	}, Group("foo,bar"))
+	require.Error(t, err, "Provide must fail")
+	assert.Contains(t, err.Error(), `cannot parse group "foo,bar": invalid option "bar"`)
 }
 
 func TestProvideGroupAndName(t *testing.T) {
diff --git a/group_test.go b/group_test.go
new file mode 100644
index 0000000..6bff0a2
--- /dev/null
+++ b/group_test.go
@@ -0,0 +1,63 @@
+// Copyright (c) 2020 Uber Technologies, Inc.
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+// THE SOFTWARE.
+
+package dig
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestParseGroup(t *testing.T) {
+	tests := []struct {
+		name    string
+		group   string
+		wantG   group
+		wantErr string
+	}{
+		{
+			name:  "simple group",
+			group: `somegroup`,
+			wantG: group{Name: "somegroup"},
+		},
+		{
+			name:  "flattened group",
+			group: `somegroup,flatten`,
+			wantG: group{Name: "somegroup", Flatten: true},
+		},
+		{
+			name:    "error",
+			group:   `somegroup,abc`,
+			wantErr: `invalid option "abc"`,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			gotG, err := parseGroupString(tt.group)
+			if tt.wantErr != "" {
+				assert.Error(t, err)
+				assert.Contains(t, err.Error(), tt.wantErr)
+				return
+			}
+			assert.Equal(t, tt.wantG, gotG)
+		})
+	}
+}
diff --git a/param_test.go b/param_test.go
index 4236e9a..22e7dbd 100644
--- a/param_test.go
+++ b/param_test.go
@@ -153,6 +153,15 @@ func TestParamGroupSliceErrors(t *testing.T) {
 			}{},
 			wantErr: "value groups cannot be optional",
 		},
+		{
+			desc: "no flatten in In",
+			shape: struct {
+				In
+
+				Foo []string `group:"foo,flatten"`
+			}{},
+			wantErr: "cannot use flatten in parameter value groups",
+		},
 	}
 
 	for _, tt := range tests {
diff --git a/result_test.go b/result_test.go
index a9d5d74..a870ec5 100644
--- a/result_test.go
+++ b/result_test.go
@@ -279,6 +279,15 @@ func TestNewResultObjectErrors(t *testing.T) {
 			opts: resultOptions{Name: "foo"},
 			err:  `cannot specify a name for result objects`,
 		},
+		{
+			desc: "flatten on non-slice",
+			give: struct {
+				Out
+
+				Writer io.Writer `group:"writers,flatten"`
+			}{},
+			err: "flatten can be applied to slices only",
+		},
 	}
 
 	for _, tt := range tests {