diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 000000000..9540cfabc --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,19 @@ +{ + "permissions": { + "allow": [ + "WebFetch(domain:github.com)", + "Bash(gh issue view:*)", + "Bash(go test:*)", + "Bash(go run:*)", + "Bash(go mod why:*)", + "Bash(go list:*)", + "Read(//Users/qjeremy/go/pkg/mod/github.com/jquirke/dig@v0.0.0-20250929003136-0b0022552f09/**)", + "Read(//Users/qjeremy/github/jquirke/**)", + "Bash(git checkout:*)", + "Bash(sed:*)", + "Bash(go vet:*)" + ], + "deny": [], + "ask": [] + } +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 5171c2d69..93a25c22e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## Unreleased -- No changes yet. + +### Added +- Support for map value groups consumption via `map[string]T` for named value groups, + enabling both individual named access and map-based access to the same providers. +- Comprehensive test coverage for map value groups functionality including decoration, + edge cases, and validation of slice decorator restrictions. ## [1.24.0](https://github.com/uber-go/fx/compare/v1.23.0...v1.24.0) - 2025-05-13 diff --git a/annotated.go b/annotated.go index 4a732f1e3..2930eec9a 100644 --- a/annotated.go +++ b/annotated.go @@ -65,13 +65,15 @@ type Annotated struct { // by the constructor. For more information on named values, see the documentation // for the fx.Out type. // - // A name option may not be provided if a group option is provided. + // Name can be used together with Group to create named value groups that can be + // consumed both individually by name and collectively as a map. Name string // If specified, this will be used as the group name for all non-error values returned // by the constructor. For more information on value groups, see the package documentation. // - // A group option may not be provided if a name option is provided. + // Group can be used together with Name to create named value groups that can be + // consumed both individually by name and collectively as a map or slice. // // Similar to group tags, the group name may be followed by a `,flatten` // option to indicate that each element in the slice returned by the diff --git a/annotated_test.go b/annotated_test.go index e10fb120c..3b2c1a7f4 100644 --- a/annotated_test.go +++ b/annotated_test.go @@ -1226,6 +1226,36 @@ func TestAnnotate(t *testing.T) { require.NoError(t, app.Err()) }) + t.Run("Invoke function with soft map group param", func(t *testing.T) { + t.Parallel() + newF := func(fooMap map[string]int, bar string) { + expected := map[string]int{"executed": 10} + assert.Equal(t, expected, fooMap) + assert.Equal(t, "hello", bar) + } + app := fxtest.New(t, + fx.Provide( + fx.Annotate( + func() (int, string) { return 10, "hello" }, + fx.ResultTags(`name:"executed" group:"foos"`, ``), + ), + fx.Annotate( + func() int { + require.FailNow(t, "this function should not be called") + return 20 + }, + fx.ResultTags(`name:"not_executed" group:"foos"`), + ), + ), + fx.Invoke( + fx.Annotate(newF, fx.ParamTags(`group:"foos,soft"`, ``)), + ), + ) + + defer app.RequireStart().RequireStop() + require.NoError(t, app.Err()) + }) + t.Run("Invoke variadic function with multiple params", func(t *testing.T) { t.Parallel() diff --git a/decorate_test.go b/decorate_test.go index ee59a099b..2c562af9f 100644 --- a/decorate_test.go +++ b/decorate_test.go @@ -21,6 +21,7 @@ package fx_test import ( + "context" "errors" "strings" "testing" @@ -235,6 +236,56 @@ func TestDecorateSuccess(t *testing.T) { require.NoError(t, app.Err()) }) + t.Run("decorator with soft map group", func(t *testing.T) { + app := fxtest.New(t, + fx.Provide( + fx.Annotate( + func() (string, int) { return "cheeseburger", 15 }, + fx.ResultTags(`name:"cheese" group:"burger"`, `name:"cheese" group:"potato"`), + ), + ), + fx.Provide( + fx.Annotate( + func() (string, int) { return "mushroomburger", 35 }, + fx.ResultTags(`name:"mushroom" group:"burger"`, `name:"mushroom" group:"potato"`), + ), + ), + fx.Provide( + fx.Annotate( + func() string { + require.FailNow(t, "should not be called") + return "veggieburger" + }, + fx.ResultTags(`name:"veggie" group:"burger"`, `name:"veggie" group:"potato"`), + ), + ), + fx.Decorate( + fx.Annotate( + func(burgers map[string]string) map[string]string { + retBurg := make(map[string]string) + for key, burger := range burgers { + retBurg[key] = strings.ToUpper(burger) + } + return retBurg + }, + fx.ParamTags(`group:"burger,soft"`), + fx.ResultTags(`group:"burger"`), + ), + ), + fx.Invoke( + fx.Annotate( + func(burgers map[string]string, fries map[string]int) { + expected := map[string]string{"cheese": "CHEESEBURGER", "mushroom": "MUSHROOMBURGER"} + assert.Equal(t, expected, burgers) + }, + fx.ParamTags(`group:"burger,soft"`, `group:"potato"`), + ), + ), + ) + defer app.RequireStart().RequireStop() + require.NoError(t, app.Err()) + }) + t.Run("decorator with optional parameter", func(t *testing.T) { type Config struct { Name string @@ -485,4 +536,454 @@ func TestDecorateFailure(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "missing dependencies") }) + + t.Run("slice decorators are blocked for named value groups", func(t *testing.T) { + // This test verifies the key design decision from dig PR #381: + // Slice decorators cannot be used with named value groups because + // they would break the map functionality + + type Service struct { + Name string + } + + type DecorationInput struct { + fx.In + // Try to consume as slice for decoration + Services []Service `group:"services"` + } + + type DecorationOutput struct { + fx.Out + // Output as slice - this should break map consumption + Services []Service `group:"services"` + } + + sliceDecorator := func(input DecorationInput) DecorationOutput { + // This slice decorator executes due to current dig limitation + // but consumption will fail with proper validation error + enhanced := make([]Service, len(input.Services)) + for i, service := range input.Services { + enhanced[i] = Service{Name: "[DECORATED]" + service.Name} + } + return DecorationOutput{Services: enhanced} + } + + app := NewForTest(t, + fx.Provide( + // Provide with names (making this a named value group) + fx.Annotate( + func() Service { return Service{Name: "auth"} }, + fx.ResultTags(`name:"auth" group:"services"`), + ), + fx.Annotate( + func() Service { return Service{Name: "billing"} }, + fx.ResultTags(`name:"billing" group:"services"`), + ), + ), + // This slice decorator should be blocked for named value groups + fx.Decorate(sliceDecorator), + // Try to consume as slice - this should trigger the dig validation + fx.Invoke(fx.Annotate( + func(serviceSlice []Service) { + t.Logf("ServiceSlice length: %d", len(serviceSlice)) + }, + fx.ParamTags(`group:"services"`), + )), + ) + + // Decoration fails at invoke/start time, not decorate time + err := app.Start(context.Background()) + defer app.Stop(context.Background()) + + // Should ALWAYS fail with the specific dig validation error + require.Error(t, err, "Slice consumption after slice decoration of named groups should fail") + + // Should get the specific dig error about slice decoration + assert.Contains(t, err.Error(), "cannot use slice decoration for value group", + "Expected dig slice decoration error, got: %v", err) + assert.Contains(t, err.Error(), "group contains named values", + "Expected error about named values, got: %v", err) + assert.Contains(t, err.Error(), "use map[string]T decorator instead", + "Expected suggestion to use map decorator, got: %v", err) + }) + + t.Run("map decorators work fine with named value groups", func(t *testing.T) { + // This test shows the contrast - map decorators work perfectly + // with named value groups, unlike slice decorators which break them + + type Service struct { + Name string + } + + type DecorationInput struct { + fx.In + // Consume as map for decoration - this works fine + Services map[string]Service `group:"services"` + } + + type DecorationOutput struct { + fx.Out + // Output as map - preserves the name-to-value mapping + Services map[string]Service `group:"services"` + } + + mapDecorator := func(input DecorationInput) DecorationOutput { + // This decorator preserves the map structure and names + enhanced := make(map[string]Service) + for name, service := range input.Services { + enhanced[name] = Service{Name: "[MAP_DECORATED]" + service.Name} + } + return DecorationOutput{Services: enhanced} + } + + type FinalParams struct { + fx.In + // Consume as map after map decoration - this should work perfectly + ServiceMap map[string]Service `group:"services"` + } + + var params FinalParams + app := NewForTest(t, + fx.Provide( + // Provide with names (making this a named value group) + fx.Annotate( + func() Service { return Service{Name: "auth"} }, + fx.ResultTags(`name:"auth" group:"services"`), + ), + fx.Annotate( + func() Service { return Service{Name: "billing"} }, + fx.ResultTags(`name:"billing" group:"services"`), + ), + ), + // This map decorator should work fine with named value groups + fx.Decorate(mapDecorator), + fx.Populate(¶ms), + ) + + // Should succeed - map decoration preserves map functionality + err := app.Start(context.Background()) + defer app.Stop(context.Background()) + + require.NoError(t, err, "Map decoration should work fine with named value groups") + + // Verify the final populated params also work correctly + require.Len(t, params.ServiceMap, 2) + assert.Equal(t, "[MAP_DECORATED]auth", params.ServiceMap["auth"].Name) + assert.Equal(t, "[MAP_DECORATED]billing", params.ServiceMap["billing"].Name) + }) +} + +// Test processor types for map decoration tests +type testProcessor interface { + Process(input string) string + Name() string +} + +type testBasicProcessor struct { + name string +} + +func (b *testBasicProcessor) Process(input string) string { + return b.name + ": " + input +} + +func (b *testBasicProcessor) Name() string { + return b.name +} + +type testEnhancedProcessor struct { + wrapped testProcessor + prefix string +} + +func (e *testEnhancedProcessor) Process(input string) string { + return e.prefix + " " + e.wrapped.Process(input) +} + +func (e *testEnhancedProcessor) Name() string { + return e.wrapped.Name() +} + +// TestMapValueGroupsDecoration tests decoration of map value groups +func TestMapValueGroupsDecoration(t *testing.T) { + t.Parallel() + + t.Run("decorate map value groups", func(t *testing.T) { + t.Parallel() + + type DecorationInput struct { + fx.In + Processors map[string]testProcessor `group:"processors"` + } + + type DecorationOutput struct { + fx.Out + Processors map[string]testProcessor `group:"processors"` + } + + decorateProcessors := func(input DecorationInput) DecorationOutput { + enhanced := make(map[string]testProcessor) + for name, processor := range input.Processors { + enhanced[name] = &testEnhancedProcessor{ + wrapped: processor, + prefix: "[ENHANCED]", + } + } + return DecorationOutput{Processors: enhanced} + } + + type FinalParams struct { + fx.In + Processors map[string]testProcessor `group:"processors"` + } + + var params FinalParams + app := NewForTest(t, + fx.Provide( + fx.Annotate( + func() testProcessor { return &testBasicProcessor{name: "json"} }, + fx.ResultTags(`name:"json" group:"processors"`), + ), + fx.Annotate( + func() testProcessor { return &testBasicProcessor{name: "xml"} }, + fx.ResultTags(`name:"xml" group:"processors"`), + ), + ), + fx.Decorate(decorateProcessors), + fx.Populate(¶ms), + ) + + err := app.Start(context.Background()) + defer app.Stop(context.Background()) + require.NoError(t, err) + + require.Len(t, params.Processors, 2) + + // Test that processors are decorated + jsonResult := params.Processors["json"].Process("data") + assert.Equal(t, "[ENHANCED] json: data", jsonResult) + + xmlResult := params.Processors["xml"].Process("data") + assert.Equal(t, "[ENHANCED] xml: data", xmlResult) + + // Names should be preserved + assert.Equal(t, "json", params.Processors["json"].Name()) + assert.Equal(t, "xml", params.Processors["xml"].Name()) + }) + + t.Run("single decoration layer", func(t *testing.T) { + t.Parallel() + + type DecorationInput struct { + fx.In + Processors map[string]testProcessor `group:"processors"` + } + + type DecorationOutput struct { + fx.Out + Processors map[string]testProcessor `group:"processors"` + } + + decoration := func(input DecorationInput) DecorationOutput { + enhanced := make(map[string]testProcessor) + for name, processor := range input.Processors { + enhanced[name] = &testEnhancedProcessor{ + wrapped: processor, + prefix: "[DECORATED]", + } + } + return DecorationOutput{Processors: enhanced} + } + + type FinalParams struct { + fx.In + Processors map[string]testProcessor `group:"processors"` + } + + var params FinalParams + app := NewForTest(t, + fx.Provide( + fx.Annotate( + func() testProcessor { return &testBasicProcessor{name: "base"} }, + fx.ResultTags(`name:"base" group:"processors"`), + ), + ), + fx.Decorate(decoration), + fx.Populate(¶ms), + ) + + err := app.Start(context.Background()) + defer app.Stop(context.Background()) + require.NoError(t, err) + + require.Len(t, params.Processors, 1) + + // Test decoration + result := params.Processors["base"].Process("test") + assert.Equal(t, "[DECORATED] base: test", result) + }) + + t.Run("decoration preserves map keys", func(t *testing.T) { + t.Parallel() + + type DecorationInput struct { + fx.In + Processors map[string]testProcessor `group:"processors"` + } + + type DecorationOutput struct { + fx.Out + Processors map[string]testProcessor `group:"processors"` + } + + var decorationInputKeys []string + var decorationOutputKeys []string + + decorateWithKeyTracking := func(input DecorationInput) DecorationOutput { + decorationInputKeys = make([]string, 0, len(input.Processors)) + for key := range input.Processors { + decorationInputKeys = append(decorationInputKeys, key) + } + + enhanced := make(map[string]testProcessor) + for name, processor := range input.Processors { + enhanced[name] = &testEnhancedProcessor{ + wrapped: processor, + prefix: "[TRACKED]", + } + } + + decorationOutputKeys = make([]string, 0, len(enhanced)) + for key := range enhanced { + decorationOutputKeys = append(decorationOutputKeys, key) + } + + return DecorationOutput{Processors: enhanced} + } + + type FinalParams struct { + fx.In + Processors map[string]testProcessor `group:"processors"` + } + + var params FinalParams + app := NewForTest(t, + fx.Provide( + fx.Annotate( + func() testProcessor { return &testBasicProcessor{name: "alpha"} }, + fx.ResultTags(`name:"alpha" group:"processors"`), + ), + fx.Annotate( + func() testProcessor { return &testBasicProcessor{name: "beta"} }, + fx.ResultTags(`name:"beta" group:"processors"`), + ), + fx.Annotate( + func() testProcessor { return &testBasicProcessor{name: "gamma"} }, + fx.ResultTags(`name:"gamma" group:"processors"`), + ), + ), + fx.Decorate(decorateWithKeyTracking), + fx.Populate(¶ms), + ) + + err := app.Start(context.Background()) + defer app.Stop(context.Background()) + require.NoError(t, err) + + require.Len(t, params.Processors, 3) + + // Verify keys are preserved through decoration + assert.ElementsMatch(t, []string{"alpha", "beta", "gamma"}, decorationInputKeys) + assert.ElementsMatch(t, []string{"alpha", "beta", "gamma"}, decorationOutputKeys) + + // Verify final map has correct keys + finalKeys := make([]string, 0, len(params.Processors)) + for key := range params.Processors { + finalKeys = append(finalKeys, key) + } + assert.ElementsMatch(t, []string{"alpha", "beta", "gamma"}, finalKeys) + }) + + t.Run("map decoration across modules", func(t *testing.T) { + t.Parallel() + + type DecorationInput struct { + fx.In + Processors map[string]testProcessor `group:"processors"` + } + + type DecorationOutput struct { + fx.Out + Processors map[string]testProcessor `group:"processors"` + } + + var outerProcessors map[string]testProcessor + var innerProcessors map[string]testProcessor + + app := fxtest.New(t, + fx.Provide( + fx.Annotate( + func() testProcessor { return &testBasicProcessor{name: "auth"} }, + fx.ResultTags(`name:"auth" group:"processors"`), + ), + fx.Annotate( + func() testProcessor { return &testBasicProcessor{name: "billing"} }, + fx.ResultTags(`name:"billing" group:"processors"`), + ), + ), + fx.Decorate(func(input DecorationInput) DecorationOutput { + enhanced := make(map[string]testProcessor) + for name, processor := range input.Processors { + enhanced[name] = &testEnhancedProcessor{ + wrapped: processor, + prefix: "[OUTER]", + } + } + return DecorationOutput{Processors: enhanced} + }), + fx.Invoke(fx.Annotate( + func(processors map[string]testProcessor) { + outerProcessors = processors + }, + fx.ParamTags(`group:"processors"`), + )), + fx.Module("mymodule", + fx.Decorate(func(input DecorationInput) DecorationOutput { + enhanced := make(map[string]testProcessor) + for name, processor := range input.Processors { + enhanced[name] = &testEnhancedProcessor{ + wrapped: processor, + prefix: "[INNER]", + } + } + return DecorationOutput{Processors: enhanced} + }), + fx.Invoke(fx.Annotate( + func(processors map[string]testProcessor) { + innerProcessors = processors + }, + fx.ParamTags(`group:"processors"`), + )), + ), + ) + defer app.RequireStart().RequireStop() + + t.Logf("Outer processors:") + for name, p := range outerProcessors { + t.Logf(" %s: %s", name, p.Process("data")) + } + t.Logf("Inner processors:") + for name, p := range innerProcessors { + t.Logf(" %s: %s", name, p.Process("data")) + } + + // Test that map decoration chains across modules + require.Len(t, outerProcessors, 2) + assert.Equal(t, "[OUTER] auth: data", outerProcessors["auth"].Process("data")) + assert.Equal(t, "[OUTER] billing: data", outerProcessors["billing"].Process("data")) + + require.Len(t, innerProcessors, 2) + assert.Equal(t, "[INNER] [OUTER] auth: data", innerProcessors["auth"].Process("data")) + assert.Equal(t, "[INNER] [OUTER] billing: data", innerProcessors["billing"].Process("data")) + }) } diff --git a/doc.go b/doc.go index 4ee2227ac..f1d98387e 100644 --- a/doc.go +++ b/doc.go @@ -247,6 +247,65 @@ // Note that values in a value group are unordered. Fx makes no guarantees // about the order in which these values will be produced. // +// # Named Value Groups and Map Consumption +// +// Value groups can be enhanced with names to enable more flexible consumption +// patterns. When values are provided with both names and group tags, they can +// be consumed in multiple ways: individually by name, as a slice for iteration, +// or as a map for direct key-based access. +// +// To provide named values to a group, use both name and group annotations. +// This can be done with fx.Out struct tags: +// +// type NamedHandlerResult struct { +// fx.Out +// +// AdminHandler Handler `name:"admin" group:"server"` +// UserHandler Handler `name:"user" group:"server"` +// } +// +// Or equivalently with fx.Annotate and fx.ResultTags: +// +// fx.Provide( +// fx.Annotate(NewAdminHandler, fx.ResultTags(`name:"admin" group:"server"`)), +// fx.Annotate(NewUserHandler, fx.ResultTags(`name:"user" group:"server"`)), +// ) +// +// Both approaches provide the same functionality and can be used interchangeably. +// +// Named value groups support multiple consumption patterns: +// +// type ServerParams struct { +// fx.In +// +// // Map consumption - direct access by name +// HandlerMap map[string]Handler `group:"server"` +// // Slice consumption - for iteration +// HandlerSlice []Handler `group:"server"` +// // Individual access by name +// AdminHandler Handler `name:"admin"` +// } +// +// func NewServer(p ServerParams) *Server { +// server := newServer() +// +// // Use map for direct access +// if admin, ok := p.HandlerMap["admin"]; ok { +// server.SetAdminHandler(admin) +// } +// +// // Use slice for iteration +// for _, handler := range p.HandlerSlice { +// server.Register(handler) +// } +// +// return server +// } +// +// Map consumption is only available for named value groups. All values in +// the group must have names, and only string-keyed maps (map[string]T) are +// supported. +// // # Soft Value Groups // // By default, when a constructor declares a dependency on a value group, diff --git a/docs/ex/value-groups/maps/consume.go b/docs/ex/value-groups/maps/consume.go new file mode 100644 index 000000000..d95cbf50c --- /dev/null +++ b/docs/ex/value-groups/maps/consume.go @@ -0,0 +1,79 @@ +// Copyright (c) 2022 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 maps + +import "go.uber.org/fx" + +// NotificationService sends notifications using various handlers. +type NotificationService struct { + handlers map[string]Handler +} + +// Send sends a notification using the specified handler type. +func (s *NotificationService) Send(handlerType, message string) string { + if handler, exists := s.handlers[handlerType]; exists { + return handler.Handle(message) + } + return "unknown handler: " + handlerType +} + +// GetAvailableHandlers returns a list of available handler types. +func (s *NotificationService) GetAvailableHandlers() []string { + var types []string + for handlerType := range s.handlers { + types = append(types, handlerType) + } + return types +} + +// ConsumeModule demonstrates consuming handlers from a named value group as a map. +var ConsumeModule = fx.Options( + fx.Provide( + // --8<-- [start:consume-map] + fx.Annotate( + NewNotificationService, + fx.ParamTags(`group:"handlers"`), + ), + // --8<-- [end:consume-map] + ), +) + +// NewNotificationService creates a notification service that consumes handlers as a map. +// --8<-- [start:new-service] +func NewNotificationService(handlers map[string]Handler) *NotificationService { + return &NotificationService{ + handlers: handlers, + } +} + +// NewNotificationServiceFromSlice creates a notification service from a slice of handlers. +// This is the traditional way of consuming value groups. +// --8<-- [start:new-service-slice] +func NewNotificationServiceFromSlice(handlers []Handler) *NotificationService { + handlerMap := make(map[string]Handler) + // Note: With slice consumption, you lose the name information + // and would need to implement your own naming strategy + return &NotificationService{ + handlers: handlerMap, + } +} + +// --8<-- [end:new-service-slice] diff --git a/docs/ex/value-groups/maps/example.go b/docs/ex/value-groups/maps/example.go new file mode 100644 index 000000000..a7bda577e --- /dev/null +++ b/docs/ex/value-groups/maps/example.go @@ -0,0 +1,47 @@ +// Copyright (c) 2022 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 maps + +import ( + "fmt" + + "go.uber.org/fx" +) + +// ExampleModule demonstrates a complete map value groups example. +var ExampleModule = fx.Options( + FeedModule, + ConsumeModule, + fx.Invoke(RunExample), +) + +// RunExample demonstrates using the notification service with map-based handlers. +func RunExample(service *NotificationService) { + fmt.Println("Available handlers:", service.GetAvailableHandlers()) + + // Send notifications using different handlers + fmt.Println(service.Send("email", "Welcome to our service!")) + fmt.Println(service.Send("slack", "Build completed successfully")) + fmt.Println(service.Send("webhook", "User registered")) + + // Try an unknown handler + fmt.Println(service.Send("sms", "This won't work")) +} diff --git a/docs/ex/value-groups/maps/feed.go b/docs/ex/value-groups/maps/feed.go new file mode 100644 index 000000000..61131a190 --- /dev/null +++ b/docs/ex/value-groups/maps/feed.go @@ -0,0 +1,51 @@ +// Copyright (c) 2022 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 maps + +import "go.uber.org/fx" + +// FeedModule demonstrates feeding handlers into a named value group +// that can be consumed as a map. +var FeedModule = fx.Options( + fx.Provide( + // --8<-- [start:feed-email] + fx.Annotate( + NewEmailHandler, + fx.As(new(Handler)), + fx.ResultTags(`name:"email" group:"handlers"`), + ), + // --8<-- [end:feed-email] + // --8<-- [start:feed-slack] + fx.Annotate( + NewSlackHandler, + fx.As(new(Handler)), + fx.ResultTags(`name:"slack" group:"handlers"`), + ), + // --8<-- [end:feed-slack] + // --8<-- [start:feed-webhook] + fx.Annotate( + NewWebhookHandler, + fx.As(new(Handler)), + fx.ResultTags(`name:"webhook" group:"handlers"`), + ), + // --8<-- [end:feed-webhook] + ), +) diff --git a/docs/ex/value-groups/maps/handler.go b/docs/ex/value-groups/maps/handler.go new file mode 100644 index 000000000..7bdc48249 --- /dev/null +++ b/docs/ex/value-groups/maps/handler.go @@ -0,0 +1,65 @@ +// Copyright (c) 2022 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 maps + +// Handler processes requests. +type Handler interface { + Handle(data string) string +} + +// EmailHandler handles email notifications. +type EmailHandler struct{} + +// Handle processes email notifications. +func (h *EmailHandler) Handle(data string) string { + return "email: " + data +} + +// SlackHandler handles Slack notifications. +type SlackHandler struct{} + +// Handle processes Slack notifications. +func (h *SlackHandler) Handle(data string) string { + return "slack: " + data +} + +// WebhookHandler handles webhook notifications. +type WebhookHandler struct{} + +// Handle processes webhook notifications. +func (h *WebhookHandler) Handle(data string) string { + return "webhook: " + data +} + +// NewEmailHandler creates a new email handler. +func NewEmailHandler() *EmailHandler { + return &EmailHandler{} +} + +// NewSlackHandler creates a new Slack handler. +func NewSlackHandler() *SlackHandler { + return &SlackHandler{} +} + +// NewWebhookHandler creates a new webhook handler. +func NewWebhookHandler() *WebhookHandler { + return &WebhookHandler{} +} diff --git a/docs/ex/value-groups/maps/maps_test.go b/docs/ex/value-groups/maps/maps_test.go new file mode 100644 index 000000000..5353c26f2 --- /dev/null +++ b/docs/ex/value-groups/maps/maps_test.go @@ -0,0 +1,96 @@ +// Copyright (c) 2022 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 maps_test + +import ( + "sort" + "testing" + + "github.com/stretchr/testify/assert" + "go.uber.org/fx" + "go.uber.org/fx/docs/ex/value-groups/maps" + "go.uber.org/fx/fxtest" +) + +func TestMapValueGroups(t *testing.T) { + t.Parallel() + + t.Run("consume handlers as map", func(t *testing.T) { + t.Parallel() + + var service *maps.NotificationService + app := fxtest.New(t, + maps.FeedModule, + maps.ConsumeModule, + fx.Populate(&service), + ) + defer app.RequireStart().RequireStop() + + // Test that we can access handlers by name + result := service.Send("email", "Hello World") + assert.Equal(t, "email: Hello World", result) + + result = service.Send("slack", "Hello World") + assert.Equal(t, "slack: Hello World", result) + + result = service.Send("webhook", "Hello World") + assert.Equal(t, "webhook: Hello World", result) + + // Test unknown handler + result = service.Send("unknown", "Hello World") + assert.Equal(t, "unknown handler: unknown", result) + + // Test that all handlers are available + handlers := service.GetAvailableHandlers() + sort.Strings(handlers) + assert.Equal(t, []string{"email", "slack", "webhook"}, handlers) + }) + + t.Run("mixed consumption - both map and slice", func(t *testing.T) { + t.Parallel() + + type Params struct { + fx.In + HandlerMap map[string]maps.Handler `group:"handlers"` + HandlerSlice []maps.Handler `group:"handlers"` + } + + var params Params + app := fxtest.New(t, + maps.FeedModule, + fx.Populate(¶ms), + ) + defer app.RequireStart().RequireStop() + + // Both map and slice should contain the same handlers + assert.Len(t, params.HandlerMap, 3) + assert.Len(t, params.HandlerSlice, 3) + + // Map should be indexed by name + assert.Contains(t, params.HandlerMap, "email") + assert.Contains(t, params.HandlerMap, "slack") + assert.Contains(t, params.HandlerMap, "webhook") + + // Test that map entries work correctly + result := params.HandlerMap["email"].Handle("test") + assert.Equal(t, "email: test", result) + }) +} diff --git a/docs/go.mod b/docs/go.mod index 33258dadc..d6a370210 100644 --- a/docs/go.mod +++ b/docs/go.mod @@ -18,3 +18,5 @@ require ( ) replace go.uber.org/fx => ../ + +replace go.uber.org/dig => github.com/jquirke/dig v0.0.0-20250929071627-bc17fef680e7 diff --git a/docs/go.sum b/docs/go.sum index 2b5e86457..c56106377 100644 --- a/docs/go.sum +++ b/docs/go.sum @@ -1,6 +1,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/jquirke/dig v0.0.0-20250929071627-bc17fef680e7 h1:r0AkrwzJWUHZSjDOXHRb3qWjUyKlEbFGqovWiD1UdmM= +github.com/jquirke/dig v0.0.0-20250929071627-bc17fef680e7/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -10,8 +12,6 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4= -go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 0b89b4026..027f9678e 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -163,6 +163,9 @@ nav: - value-groups/index.md - value-groups/feed.md - value-groups/consume.md + - Maps: + - value-groups/maps/feed.md + - value-groups/maps/consume.md - FAQ: faq.md - Community: - Contributing: contributing.md diff --git a/docs/src/value-groups/maps/consume.md b/docs/src/value-groups/maps/consume.md new file mode 100644 index 000000000..d0da597bd --- /dev/null +++ b/docs/src/value-groups/maps/consume.md @@ -0,0 +1,97 @@ +# Consuming Map Value Groups + +Map value groups can be consumed as maps indexed by their names, providing direct access +to specific values without iteration. This is more efficient and clearer than traditional +slice consumption when you need to access specific handlers by name. + +## Map Consumption + +To consume a value group as a map, declare a parameter with the map type and group tag: + +```go +--8<-- "value-groups/maps/consume.go:consume-map" +``` + +The constructor can then accept the handlers as a map: + +```go +--8<-- "value-groups/maps/consume.go:new-service" +``` + +With map consumption, you get: + +- **Direct access**: `handlers["email"]` instead of iterating through a slice +- **Type safety**: The compiler knows the key type is `string` and value type is `Handler` +- **Performance**: O(1) lookup time instead of O(n) iteration +- **Clarity**: The names make it clear what each handler does + +## Mixed Consumption + +You can consume the same value group as both a map and a slice: + +```go +type Params struct { + fx.In + HandlerMap map[string]Handler `group:"handlers"` // Map consumption + HandlerSlice []Handler `group:"handlers"` // Slice consumption +} +``` + +This allows different parts of your application to consume the same group in the most appropriate way. + +## Slice Consumption (Traditional) + +For comparison, here's the traditional slice consumption approach: + +```go +--8<-- "value-groups/maps/consume.go:consume-slice" +``` + +```go +--8<-- "value-groups/maps/consume.go:new-service-slice" +``` + +With slice consumption, you lose the name information and must implement your own lookup logic. + +## When to Use Maps vs Slices + +**Use map consumption when:** + +- You need to access specific handlers by name +- You want O(1) lookup performance +- The names provide meaningful semantic information +- You're building registries or routing systems + +**Use slice consumption when:** + +- You need to iterate over all values +- The order of processing matters (though value group order is not guaranteed) +- Names are not semantically meaningful +- You're migrating existing code gradually + +## Map Key Types + +Currently, map value groups only support `string` keys. The names you provide +in the `name` tags become the keys in the consumed map. + +## Error Handling + +When consuming maps, handle missing keys appropriately: + +```go +func (s *NotificationService) Send(handlerType, message string) string { + if handler, exists := s.handlers[handlerType]; exists { + return handler.Handle(message) + } + return "unknown handler: " + handlerType +} +``` + +## Requirements + +For map consumption to work: + +1. **Named values**: All values in the group must have both `name` and `group` tags +2. **String keys**: Map keys must be of type `string` +3. **Unique names**: All names within a group must be unique +4. **Compatible types**: All values must be assignable to the map's value type \ No newline at end of file diff --git a/docs/src/value-groups/maps/feed.md b/docs/src/value-groups/maps/feed.md new file mode 100644 index 000000000..1da0cd366 --- /dev/null +++ b/docs/src/value-groups/maps/feed.md @@ -0,0 +1,79 @@ +# Feeding Map Value Groups + +Map value groups allow you to provide values that can be consumed as a map indexed by name, +rather than just as a slice. To enable map consumption, values must be provided with both +a `name` and a `group` tag. + +## Basic Map Feeding + +To feed values into a map value group, use both `name` and `group` tags in `fx.ResultTags`: + +```go +--8<-- "value-groups/maps/feed.go:feed-email" +``` + +The key requirements are: + +1. **Both tags required**: Values must have both `name:"key"` and `group:"groupname"` tags +2. **Unique names**: Each name within a group must be unique +3. **Same group**: All values intended for the same map must use the same group name + +## Multiple Handlers + +You can provide multiple handlers to the same group with different names: + +```go +--8<-- "value-groups/maps/feed.go:feed-slack" +``` + +```go +--8<-- "value-groups/maps/feed.go:feed-webhook" +``` + +## Interface Casting + +When providing concrete types that should be consumed as interfaces, +use `fx.As` to cast the type: + +```go +fx.Annotate( + NewEmailHandler, + fx.As(new(Handler)), // Cast to interface + fx.ResultTags(`name:"email" group:"handlers"`), +) +``` + +This ensures that consumers receive the interface type rather than the concrete implementation. + +## Naming Strategy + +Choose meaningful, unique names that describe the purpose or type of each handler: + +- Use descriptive names: `"email"`, `"slack"`, `"webhook"` +- Avoid generic names: `"handler1"`, `"handler2"` +- Be consistent: Use a naming convention across your application + +!!! tip + + The names you choose will become the keys in the map when consumed. + Make them descriptive and meaningful to consumers. + +## Decoration Restrictions + +Map value groups have specific restrictions when used with decorators: + +- **Slice decorations are forbidden**: You cannot decorate a map value group consumed as a slice +- **Map decorations are allowed**: You can decorate map value groups when consumed as maps +- **Mixed consumption**: If a group is consumed both as a map and slice, decoration is not permitted + +This restriction exists because decorating slices within map groups would create inconsistencies +between the map and slice representations of the same group. + +## Error Conditions + +Map value groups will fail if: + +- A value has a `group` tag but no `name` tag +- Two values in the same group have the same name +- The map consumer's key type doesn't match the name type (must be `string`) +- You attempt to decorate a slice when the group is also consumed as a map \ No newline at end of file diff --git a/go.mod b/go.mod index af2a10ac7..c0f20f3d1 100644 --- a/go.mod +++ b/go.mod @@ -16,3 +16,5 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) + +replace go.uber.org/dig => github.com/jquirke/dig v0.0.0-20250929071627-bc17fef680e7 diff --git a/go.sum b/go.sum index ac0978465..2b1e777b4 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/jquirke/dig v0.0.0-20250929071627-bc17fef680e7 h1:r0AkrwzJWUHZSjDOXHRb3qWjUyKlEbFGqovWiD1UdmM= +github.com/jquirke/dig v0.0.0-20250929071627-bc17fef680e7/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -14,8 +16,6 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4= -go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= diff --git a/map_groups_test.go b/map_groups_test.go new file mode 100644 index 000000000..508ac5a80 --- /dev/null +++ b/map_groups_test.go @@ -0,0 +1,586 @@ +// Copyright (c) 2023 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 fx_test + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/fx" + "go.uber.org/fx/fxtest" +) + +// Test types for map value groups +type mapTestLogger interface { + Log(message string) string + Name() string +} + +type mapTestConsoleLogger struct { + name string +} + +func (c *mapTestConsoleLogger) Log(message string) string { + return fmt.Sprintf("console[%s]: %s", c.name, message) +} + +func (c *mapTestConsoleLogger) Name() string { + return c.name +} + +type mapTestFileLogger struct { + name string +} + +func (f *mapTestFileLogger) Log(message string) string { + return fmt.Sprintf("file[%s]: %s", f.name, message) +} + +func (f *mapTestFileLogger) Name() string { + return f.name +} + +type testHandler interface { + Handle(data string) string +} + +type testEmailHandler struct{} + +func (e *testEmailHandler) Handle(data string) string { + return "email: " + data +} + +type testSlackHandler struct{} + +func (s *testSlackHandler) Handle(data string) string { + return "slack: " + data +} + +type testService struct { + name string +} + +type testConfig struct { + Key string + Value int +} + +type testHTTPHandler struct { + id string +} + +func (h *testHTTPHandler) Process(data string) string { + return fmt.Sprintf("http[%s]: %s", h.id, data) +} + +func (h *testHTTPHandler) ID() string { + return h.id +} + +type testSimpleService interface { + GetName() string +} + +type testBasicService struct { + name string +} + +func (b *testBasicService) GetName() string { + return b.name +} + +// TestMapValueGroups tests the new map value groups functionality from dig PR #381 +func TestMapValueGroups(t *testing.T) { + t.Parallel() + + t.Run("basic map consumption", func(t *testing.T) { + t.Parallel() + + type Params struct { + fx.In + // NEW: Map consumption - indexed by name + LoggerMap map[string]mapTestLogger `group:"loggers"` + // EXISTING: Slice consumption still works + LoggerSlice []mapTestLogger `group:"loggers"` + } + + var params Params + app := fxtest.New(t, + fx.Provide( + // Provide loggers with BOTH name AND group + fx.Annotate( + func() mapTestLogger { return &mapTestConsoleLogger{name: "console"} }, + fx.ResultTags(`name:"console" group:"loggers"`), + ), + fx.Annotate( + func() mapTestLogger { return &mapTestFileLogger{name: "file"} }, + fx.ResultTags(`name:"file" group:"loggers"`), + ), + ), + fx.Populate(¶ms), + ) + defer app.RequireStart().RequireStop() + + // Test map consumption + require.Len(t, params.LoggerMap, 2) + assert.Contains(t, params.LoggerMap, "console") + assert.Contains(t, params.LoggerMap, "file") + + consoleLogger := params.LoggerMap["console"] + require.NotNil(t, consoleLogger) + assert.Equal(t, "console", consoleLogger.Name()) + + fileLogger := params.LoggerMap["file"] + require.NotNil(t, fileLogger) + assert.Equal(t, "file", fileLogger.Name()) + + // Test slice consumption still works + require.Len(t, params.LoggerSlice, 2) + loggerNames := make([]string, len(params.LoggerSlice)) + for i, logger := range params.LoggerSlice { + loggerNames[i] = logger.Name() + } + assert.ElementsMatch(t, []string{"console", "file"}, loggerNames) + }) + + t.Run("map consumption with interfaces", func(t *testing.T) { + t.Parallel() + + type HandlerParams struct { + fx.In + Handlers map[string]testHandler `group:"handlers"` + } + + var params HandlerParams + app := fxtest.New(t, + fx.Provide( + fx.Annotate( + func() testHandler { return &testEmailHandler{} }, + fx.ResultTags(`name:"email" group:"handlers"`), + ), + fx.Annotate( + func() testHandler { return &testSlackHandler{} }, + fx.ResultTags(`name:"slack" group:"handlers"`), + ), + ), + fx.Populate(¶ms), + ) + defer app.RequireStart().RequireStop() + + require.Len(t, params.Handlers, 2) + assert.Equal(t, "email: test", params.Handlers["email"].Handle("test")) + assert.Equal(t, "slack: test", params.Handlers["slack"].Handle("test")) + }) + + t.Run("map consumption with pointer types", func(t *testing.T) { + t.Parallel() + + type ServiceParams struct { + fx.In + Services map[string]*testService `group:"services"` + } + + var params ServiceParams + app := fxtest.New(t, + fx.Provide( + fx.Annotate( + func() *testService { return &testService{name: "auth"} }, + fx.ResultTags(`name:"auth" group:"services"`), + ), + fx.Annotate( + func() *testService { return &testService{name: "billing"} }, + fx.ResultTags(`name:"billing" group:"services"`), + ), + ), + fx.Populate(¶ms), + ) + defer app.RequireStart().RequireStop() + + require.Len(t, params.Services, 2) + assert.Equal(t, "auth", params.Services["auth"].name) + assert.Equal(t, "billing", params.Services["billing"].name) + }) + + t.Run("empty map when no providers", func(t *testing.T) { + t.Parallel() + + type EmptyParams struct { + fx.In + Empty map[string]mapTestLogger `group:"empty"` + } + + var params EmptyParams + app := fxtest.New(t, + fx.Populate(¶ms), + ) + defer app.RequireStart().RequireStop() + + require.NotNil(t, params.Empty) + assert.Len(t, params.Empty, 0) + }) + + t.Run("value groups cannot be optional", func(t *testing.T) { + t.Parallel() + + type OptionalParams struct { + fx.In + OptionalLoggers map[string]mapTestLogger `group:"optional_loggers" optional:"true"` + } + + var params OptionalParams + app := NewForTest(t, + fx.Populate(¶ms), + ) + + // Should fail because value groups cannot be optional + err := app.Err() + require.Error(t, err) + assert.Contains(t, err.Error(), "value groups cannot be optional") + }) + + t.Run("value type maps", func(t *testing.T) { + t.Parallel() + + type ConfigParams struct { + fx.In + Configs map[string]testConfig `group:"configs"` + } + + var params ConfigParams + app := fxtest.New(t, + fx.Provide( + fx.Annotate( + func() testConfig { return testConfig{Key: "db", Value: 100} }, + fx.ResultTags(`name:"database" group:"configs"`), + ), + fx.Annotate( + func() testConfig { return testConfig{Key: "cache", Value: 200} }, + fx.ResultTags(`name:"cache" group:"configs"`), + ), + ), + fx.Populate(¶ms), + ) + defer app.RequireStart().RequireStop() + + require.Len(t, params.Configs, 2) + assert.Equal(t, "db", params.Configs["database"].Key) + assert.Equal(t, 100, params.Configs["database"].Value) + assert.Equal(t, "cache", params.Configs["cache"].Key) + assert.Equal(t, 200, params.Configs["cache"].Value) + }) +} + +// TestMapValueGroupsWithNameAndGroup tests the ability to use both dig.Name() and dig.Group() +func TestMapValueGroupsWithNameAndGroup(t *testing.T) { + t.Parallel() + + type testHandlerWithID interface { + Process(data string) string + ID() string + } + + t.Run("combine name and group annotations", func(t *testing.T) { + t.Parallel() + + type CombinedParams struct { + fx.In + // Map consumption from group + AllHandlers map[string]testHandlerWithID `group:"handlers"` + // Individual named access + PrimaryHandler testHandlerWithID `name:"primary"` + SecondaryHandler testHandlerWithID `name:"secondary"` + } + + var params CombinedParams + app := fxtest.New(t, + fx.Provide( + // This was impossible before dig PR #381! + // Now we can use BOTH name AND group + fx.Annotate( + func() testHandlerWithID { return &testHTTPHandler{id: "primary"} }, + fx.ResultTags(`name:"primary" group:"handlers"`), + ), + fx.Annotate( + func() testHandlerWithID { return &testHTTPHandler{id: "secondary"} }, + fx.ResultTags(`name:"secondary" group:"handlers"`), + ), + ), + fx.Populate(¶ms), + ) + defer app.RequireStart().RequireStop() + + // Test map access + require.Len(t, params.AllHandlers, 2) + assert.Contains(t, params.AllHandlers, "primary") + assert.Contains(t, params.AllHandlers, "secondary") + + // Test individual named access + require.NotNil(t, params.PrimaryHandler) + assert.Equal(t, "primary", params.PrimaryHandler.ID()) + require.NotNil(t, params.SecondaryHandler) + assert.Equal(t, "secondary", params.SecondaryHandler.ID()) + + // Verify they're the same instances + assert.Equal(t, params.PrimaryHandler, params.AllHandlers["primary"]) + assert.Equal(t, params.SecondaryHandler, params.AllHandlers["secondary"]) + }) + + t.Run("invoke function with both map and named dependencies", func(t *testing.T) { + t.Parallel() + + var invokeCalled bool + var mapSize int + var primaryID string + + app := fxtest.New(t, + fx.Provide( + fx.Annotate( + func() testHandlerWithID { return &testHTTPHandler{id: "main"} }, + fx.ResultTags(`name:"main" group:"handlers"`), + ), + fx.Annotate( + func() testHandlerWithID { return &testHTTPHandler{id: "backup"} }, + fx.ResultTags(`name:"backup" group:"handlers"`), + ), + ), + fx.Invoke(fx.Annotate( + func(handlers map[string]testHandlerWithID, main testHandlerWithID) { + invokeCalled = true + mapSize = len(handlers) + primaryID = main.ID() + }, + fx.ParamTags(`group:"handlers"`, `name:"main"`), + )), + ) + defer app.RequireStart().RequireStop() + + assert.True(t, invokeCalled) + assert.Equal(t, 2, mapSize) + assert.Equal(t, "main", primaryID) + }) +} + +// TestMapValueGroupsEdgeCases tests edge cases and error conditions +func TestMapValueGroupsEdgeCases(t *testing.T) { + t.Parallel() + + t.Run("duplicate names should fail", func(t *testing.T) { + t.Parallel() + + type DuplicateParams struct { + fx.In + Services map[string]testSimpleService `group:"services"` + } + + var params DuplicateParams + app := NewForTest(t, + fx.Provide( + fx.Annotate( + func() testSimpleService { return &testBasicService{name: "first"} }, + fx.ResultTags(`name:"duplicate" group:"services"`), + ), + fx.Annotate( + func() testSimpleService { return &testBasicService{name: "second"} }, + fx.ResultTags(`name:"duplicate" group:"services"`), + ), + ), + fx.Populate(¶ms), + ) + + // Should fail because duplicate names are not allowed + err := app.Err() + require.Error(t, err) + assert.Contains(t, err.Error(), "already provided") + }) + + t.Run("map groups require all entries to have names", func(t *testing.T) { + t.Parallel() + + type MixedParams struct { + fx.In + Services map[string]testSimpleService `group:"mixed"` + ServiceSlice []testSimpleService `group:"mixed"` + } + + var params MixedParams + app := NewForTest(t, + fx.Provide( + // Named service + fx.Annotate( + func() testSimpleService { return &testBasicService{name: "named"} }, + fx.ResultTags(`name:"named_service" group:"mixed"`), + ), + // Unnamed service - this should cause an error when map is requested + fx.Annotate( + func() testSimpleService { return &testBasicService{name: "unnamed"} }, + fx.ResultTags(`group:"mixed"`), + ), + ), + fx.Populate(¶ms), + ) + + // Should fail because map value groups require all entries to have names + err := app.Err() + require.Error(t, err) + assert.Contains(t, err.Error(), "every entry in a map value groups must have a name") + }) + + t.Run("invalid map key types should fail", func(t *testing.T) { + t.Parallel() + + type InvalidKeyParams struct { + fx.In + Services map[int]testSimpleService `group:"services"` + } + + var params InvalidKeyParams + app := NewForTest(t, + fx.Provide( + fx.Annotate( + func() testSimpleService { return &testBasicService{name: "test"} }, + fx.ResultTags(`name:"test" group:"services"`), + ), + ), + fx.Populate(¶ms), + ) + + err := app.Err() + require.Error(t, err) + assert.Contains(t, err.Error(), "value groups may be consumed as slices or string-keyed maps only") + }) + + t.Run("mixed consumption patterns", func(t *testing.T) { + t.Parallel() + + var mapServices map[string]testSimpleService + var sliceServices []testSimpleService + + app := fxtest.New(t, + fx.Provide( + // Named services for map consumption + fx.Annotate( + func() testSimpleService { return &testBasicService{name: "auth-service"} }, + fx.ResultTags(`name:"auth" group:"services"`), + ), + fx.Annotate( + func() testSimpleService { return &testBasicService{name: "billing-service"} }, + fx.ResultTags(`name:"billing" group:"services"`), + ), + fx.Annotate( + func() testSimpleService { return &testBasicService{name: "metrics-service"} }, + fx.ResultTags(`name:"metrics" group:"services"`), + ), + ), + fx.Invoke(fx.Annotate( + func(services map[string]testSimpleService) { + mapServices = services + }, + fx.ParamTags(`group:"services"`), + )), + fx.Invoke(fx.Annotate( + func(services []testSimpleService) { + sliceServices = services + }, + fx.ParamTags(`group:"services"`), + )), + ) + defer app.RequireStart().RequireStop() + + // Map consumption should work with named services + require.Len(t, mapServices, 3) + require.Contains(t, mapServices, "auth") + require.Contains(t, mapServices, "billing") + require.Contains(t, mapServices, "metrics") + assert.Equal(t, "auth-service", mapServices["auth"].GetName()) + assert.Equal(t, "billing-service", mapServices["billing"].GetName()) + assert.Equal(t, "metrics-service", mapServices["metrics"].GetName()) + + // Slice consumption should also work with the same services + require.Len(t, sliceServices, 3) + serviceNames := make([]string, len(sliceServices)) + for i, service := range sliceServices { + serviceNames[i] = service.GetName() + } + assert.ElementsMatch(t, []string{"auth-service", "billing-service", "metrics-service"}, serviceNames) + }) + + t.Run("fx.Out struct with name and group tags", func(t *testing.T) { + t.Parallel() + + type LoggerResult struct { + fx.Out + + ConsoleLogger mapTestLogger `name:"console" group:"loggers"` + FileLogger mapTestLogger `name:"file" group:"loggers"` + } + + type LoggerParams struct { + fx.In + + // Individual named access + ConsoleLogger mapTestLogger `name:"console"` + FileLogger mapTestLogger `name:"file"` + // Map access via group + LoggerMap map[string]mapTestLogger `group:"loggers"` + // Slice access via group + LoggerSlice []mapTestLogger `group:"loggers"` + } + + var params LoggerParams + app := fxtest.New(t, + fx.Provide(func() LoggerResult { + return LoggerResult{ + ConsoleLogger: &mapTestConsoleLogger{name: "console"}, + FileLogger: &mapTestFileLogger{name: "file"}, + } + }), + fx.Populate(¶ms), + ) + defer app.RequireStart().RequireStop() + + // Test individual named access + require.NotNil(t, params.ConsoleLogger) + require.NotNil(t, params.FileLogger) + assert.Equal(t, "console", params.ConsoleLogger.Name()) + assert.Equal(t, "file", params.FileLogger.Name()) + + // Test map access - should contain both with their names as keys + require.Len(t, params.LoggerMap, 2) + assert.Contains(t, params.LoggerMap, "console") + assert.Contains(t, params.LoggerMap, "file") + assert.Equal(t, "console", params.LoggerMap["console"].Name()) + assert.Equal(t, "file", params.LoggerMap["file"].Name()) + + // Test slice access - should contain both values + require.Len(t, params.LoggerSlice, 2) + names := make([]string, 0, 2) + for _, logger := range params.LoggerSlice { + names = append(names, logger.Name()) + } + assert.ElementsMatch(t, []string{"console", "file"}, names) + + // Verify same instances across access patterns + assert.Same(t, params.ConsoleLogger, params.LoggerMap["console"]) + assert.Same(t, params.FileLogger, params.LoggerMap["file"]) + }) +} diff --git a/module_test.go b/module_test.go index 39dc5ab8b..8690a5696 100644 --- a/module_test.go +++ b/module_test.go @@ -722,3 +722,212 @@ func TestModuleFailures(t *testing.T) { } }) } + +// Test types for map value groups in modules +type moduleTestSimpleService interface { + GetName() string +} + +type moduleTestBasicService struct { + name string +} + +func (b *moduleTestBasicService) GetName() string { + return b.name +} + +type moduleTestHandler interface { + Handle(data string) string +} + +type moduleTestEmailHandler struct{} + +func (e *moduleTestEmailHandler) Handle(data string) string { + return "email: " + data +} + +type moduleTestSlackHandler struct{} + +func (s *moduleTestSlackHandler) Handle(data string) string { + return "slack: " + data +} + +// TestModuleMapValueGroups tests map value groups across module boundaries +func TestModuleMapValueGroups(t *testing.T) { + t.Parallel() + + t.Run("map consumption across modules", func(t *testing.T) { + t.Parallel() + + type ModuleParams struct { + fx.In + Services map[string]moduleTestSimpleService `group:"services"` + } + + // Child module provides services + childModule := fx.Module("child", + fx.Provide( + fx.Annotate( + func() moduleTestSimpleService { return &moduleTestBasicService{name: "child-auth"} }, + fx.ResultTags(`name:"auth" group:"services"`), + ), + fx.Annotate( + func() moduleTestSimpleService { return &moduleTestBasicService{name: "child-billing"} }, + fx.ResultTags(`name:"billing" group:"services"`), + ), + ), + ) + + // Parent level provides more services + var params ModuleParams + app := fxtest.New(t, + childModule, + fx.Provide( + fx.Annotate( + func() moduleTestSimpleService { return &moduleTestBasicService{name: "parent-metrics"} }, + fx.ResultTags(`name:"metrics" group:"services"`), + ), + ), + fx.Populate(¶ms), + ) + defer app.RequireStart().RequireStop() + + // Should see services from both child module and parent level + require.Len(t, params.Services, 3) + assert.Equal(t, "child-auth", params.Services["auth"].GetName()) + assert.Equal(t, "child-billing", params.Services["billing"].GetName()) + assert.Equal(t, "parent-metrics", params.Services["metrics"].GetName()) + }) + + t.Run("nested modules with map groups", func(t *testing.T) { + t.Parallel() + + type NestedParams struct { + fx.In + Handlers map[string]moduleTestHandler `group:"handlers"` + } + + // Deeply nested modules + innerModule := fx.Module("inner", + fx.Provide( + fx.Annotate( + func() moduleTestHandler { return &moduleTestEmailHandler{} }, + fx.ResultTags(`name:"email" group:"handlers"`), + ), + ), + ) + + middleModule := fx.Module("middle", + innerModule, + fx.Provide( + fx.Annotate( + func() moduleTestHandler { return &moduleTestSlackHandler{} }, + fx.ResultTags(`name:"slack" group:"handlers"`), + ), + ), + ) + + var params NestedParams + app := fxtest.New(t, + middleModule, + fx.Provide( + fx.Annotate( + func() moduleTestHandler { return &moduleTestEmailHandler{} }, + fx.ResultTags(`name:"webhook" group:"handlers"`), + ), + ), + fx.Populate(¶ms), + ) + defer app.RequireStart().RequireStop() + + require.Len(t, params.Handlers, 3) + assert.Contains(t, params.Handlers, "email") + assert.Contains(t, params.Handlers, "slack") + assert.Contains(t, params.Handlers, "webhook") + }) + + t.Run("modules see global group state", func(t *testing.T) { + t.Parallel() + + var childServices map[string]moduleTestSimpleService + var parentServices map[string]moduleTestSimpleService + + childModule := fx.Module("child", + fx.Provide( + fx.Annotate( + func() moduleTestSimpleService { return &moduleTestBasicService{name: "child-service"} }, + fx.ResultTags(`name:"child" group:"services"`), + ), + ), + fx.Invoke(fx.Annotate( + func(services map[string]moduleTestSimpleService) { + childServices = services + }, + fx.ParamTags(`group:"services"`), + )), + ) + + app := fxtest.New(t, + childModule, + fx.Provide( + fx.Annotate( + func() moduleTestSimpleService { return &moduleTestBasicService{name: "parent-service"} }, + fx.ResultTags(`name:"parent" group:"services"`), + ), + ), + fx.Invoke(fx.Annotate( + func(services map[string]moduleTestSimpleService) { + parentServices = services + }, + fx.ParamTags(`group:"services"`), + )), + ) + defer app.RequireStart().RequireStop() + + // Both child and parent should see all services (fx groups are global) + require.Len(t, childServices, 2) + assert.Contains(t, childServices, "child") + assert.Contains(t, childServices, "parent") + assert.Equal(t, "child-service", childServices["child"].GetName()) + + require.Len(t, parentServices, 2) + assert.Contains(t, parentServices, "child") + assert.Contains(t, parentServices, "parent") + assert.Equal(t, "parent-service", parentServices["parent"].GetName()) + }) + + t.Run("name conflicts across modules should fail", func(t *testing.T) { + t.Parallel() + + childModule := fx.Module("child", + fx.Provide( + fx.Annotate( + func() moduleTestSimpleService { return &moduleTestBasicService{name: "child-version"} }, + fx.ResultTags(`name:"service" group:"services"`), + ), + ), + ) + + type ConflictParams struct { + fx.In + Services map[string]moduleTestSimpleService `group:"services"` + } + + var params ConflictParams + app := NewForTest(t, + childModule, + fx.Provide( + fx.Annotate( + func() moduleTestSimpleService { return &moduleTestBasicService{name: "parent-version"} }, + fx.ResultTags(`name:"service" group:"services"`), // Same name as child + ), + ), + fx.Populate(¶ms), + ) + + // Should fail due to duplicate names across modules + err := app.Err() + require.Error(t, err) + assert.Contains(t, err.Error(), "already provided") + }) +}