From 68678a5d55951b11cf7f26e662280c1ea29e00ef Mon Sep 17 00:00:00 2001 From: Jeremy Quirke Date: Sun, 28 Sep 2025 18:06:46 -0700 Subject: [PATCH 01/10] Implement map value groups support in fx MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add support for consuming named value groups as map[string]T, enabling both individual named access and map-based consumption of the same providers. While the core functionality is implemented in dig PR #381, this commit adds comprehensive fx integration and test coverage. Changes: - Add map_groups_test.go with extensive test coverage for map consumption, interfaces, pointer types, name+group combinations, and edge cases - Enhance decorate_test.go with map decoration tests and validation of slice decorator restrictions for named value groups - Update go.mod to use dig fork with map value groups support - Update CHANGELOG.md to document the new functionality Fixes #1036 ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- CHANGELOG.md | 7 +- decorate_test.go | 368 +++++++++++++++++++++++++++++ demo/go.mod | 18 ++ demo/go.sum | 18 ++ demo/map_decoration_demo.go | 258 +++++++++++++++++++++ demo/map_groups_demo.go | 232 +++++++++++++++++++ go.mod | 2 + go.sum | 4 +- map_groups_test.go | 447 ++++++++++++++++++++++++++++++++++++ 9 files changed, 1351 insertions(+), 3 deletions(-) create mode 100644 demo/go.mod create mode 100644 demo/go.sum create mode 100644 demo/map_decoration_demo.go create mode 100644 demo/map_groups_demo.go create mode 100644 map_groups_test.go 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/decorate_test.go b/decorate_test.go index ee59a099b..a2399907d 100644 --- a/decorate_test.go +++ b/decorate_test.go @@ -21,6 +21,7 @@ package fx_test import ( + "context" "errors" "strings" "testing" @@ -485,4 +486,371 @@ 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) + }) } diff --git a/demo/go.mod b/demo/go.mod new file mode 100644 index 000000000..a8cf90852 --- /dev/null +++ b/demo/go.mod @@ -0,0 +1,18 @@ +module fx-map-groups-demo + +go 1.25.1 + +require ( + go.uber.org/dig v1.19.0 + go.uber.org/fx v1.23.0 +) + +require ( + go.uber.org/multierr v1.10.0 // indirect + go.uber.org/zap v1.26.0 // indirect + golang.org/x/sys v0.0.0-20220412211240-33da011f77ad // indirect +) + +replace go.uber.org/fx => ../ + +replace go.uber.org/dig => github.com/jquirke/dig v0.0.0-20250929003136-0b0022552f09 diff --git a/demo/go.sum b/demo/go.sum new file mode 100644 index 000000000..37892e125 --- /dev/null +++ b/demo/go.sum @@ -0,0 +1,18 @@ +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-20250929003136-0b0022552f09 h1:W7QqWL+tcMYguqdg3YbttY3m20s9d+z1r7lOrL7hzSE= +github.com/jquirke/dig v0.0.0-20250929003136-0b0022552f09/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/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +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= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= +go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= +golang.org/x/sys v0.0.0-20220412211240-33da011f77ad h1:ntjMns5wyP/fN65tdBD4g8J5w8n015+iIIs9rtjXkY0= +golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/demo/map_decoration_demo.go b/demo/map_decoration_demo.go new file mode 100644 index 000000000..3a1189e4e --- /dev/null +++ b/demo/map_decoration_demo.go @@ -0,0 +1,258 @@ +package main + +import ( + "context" + "fmt" + "log" + "strings" + + "go.uber.org/fx" +) + +// ===================================================== +// DEMO: Map Value Groups + Decoration in Fx +// ===================================================== + +// Logger interface for different log outputs +type Logger interface { + Log(message string) + Name() string +} + +// FileLogger logs to files +type FileLogger struct{ filename string } + +func (l *FileLogger) Log(message string) { + fmt.Printf("๐Ÿ“ FileLogger[%s]: %s\n", l.filename, message) +} + +func (l *FileLogger) Name() string { + return "file" +} + +// ConsoleLogger logs to console +type ConsoleLogger struct{} + +func (l *ConsoleLogger) Log(message string) { + fmt.Printf("๐Ÿ–ฅ๏ธ ConsoleLogger: %s\n", message) +} + +func (l *ConsoleLogger) Name() string { + return "console" +} + +// DatabaseLogger logs to database +type DatabaseLogger struct{} + +func (l *DatabaseLogger) Log(message string) { + fmt.Printf("๐Ÿ—„๏ธ DatabaseLogger: %s\n", message) +} + +func (l *DatabaseLogger) Name() string { + return "database" +} + +// ===================================================== +// PROVIDERS +// ===================================================== + +func ProvideFileLogger() Logger { + return &FileLogger{filename: "app.log"} +} + +func ProvideConsoleLogger() Logger { + return &ConsoleLogger{} +} + +func ProvideDatabaseLogger() Logger { + return &DatabaseLogger{} +} + +// ===================================================== +// MAP DECORATION EXAMPLE +// ===================================================== + +type LoggerDecorationParams struct { + fx.In + // Input: map of loggers by name + Loggers map[string]Logger `group:"loggers"` +} + +type LoggerDecorationResult struct { + fx.Out + // Output: enhanced map of loggers + Loggers map[string]Logger `group:"loggers"` +} + +// DecorateLoggers demonstrates map decoration - add prefix to all loggers +func DecorateLoggers(params LoggerDecorationParams) LoggerDecorationResult { + fmt.Println("\n๐ŸŽจ Decorating loggers with [ENHANCED] prefix...") + + enhancedLoggers := make(map[string]Logger) + + for name, logger := range params.Loggers { + // Wrap each logger with enhancement + enhancedLoggers[name] = &EnhancedLogger{ + wrapped: logger, + prefix: "[ENHANCED]", + } + fmt.Printf(" โœจ Enhanced logger: %s\n", name) + } + + return LoggerDecorationResult{Loggers: enhancedLoggers} +} + +// EnhancedLogger wraps another logger with a prefix +type EnhancedLogger struct { + wrapped Logger + prefix string +} + +func (l *EnhancedLogger) Log(message string) { + l.wrapped.Log(fmt.Sprintf("%s %s", l.prefix, message)) +} + +func (l *EnhancedLogger) Name() string { + return l.wrapped.Name() +} + +// ===================================================== +// LOGGING SERVICE USING DECORATED MAP +// ===================================================== + +type LoggingServiceParams struct { + fx.In + + // ๐ŸŽฏ This map will contain DECORATED loggers! + LoggerMap map[string]Logger `group:"loggers"` +} + +type LoggingService struct { + loggers map[string]Logger +} + +func NewLoggingService(params LoggingServiceParams) *LoggingService { + fmt.Printf("\n๐Ÿ”ง LoggingService created with %d decorated loggers\n", len(params.LoggerMap)) + + // Show what we received + for name := range params.LoggerMap { + fmt.Printf(" ๐Ÿ“„ Logger available: %s\n", name) + } + + return &LoggingService{loggers: params.LoggerMap} +} + +func (s *LoggingService) LogToSpecific(loggerName, message string) error { + logger, exists := s.loggers[loggerName] + if !exists { + return fmt.Errorf("logger %q not found", loggerName) + } + + logger.Log(message) + return nil +} + +func (s *LoggingService) LogToAll(message string) { + fmt.Println("\n๐Ÿ“ข Broadcasting to all loggers:") + for _, logger := range s.loggers { + logger.Log(message) + } +} + +func (s *LoggingService) LogToFiltered(message string, filter func(name string) bool) { + fmt.Printf("\n๐Ÿ” Logging to filtered loggers (%s):\n", "contains 'log'") + for name, logger := range s.loggers { + if filter(name) { + logger.Log(message) + } + } +} + +// ===================================================== +// APPLICATION LIFECYCLE +// ===================================================== + +func RunDecorationDemo(lc fx.Lifecycle, service *LoggingService) { + lc.Append(fx.Hook{ + OnStart: func(ctx context.Context) error { + fmt.Println("\n๐Ÿš€ Starting Map Decoration Demo") + fmt.Println("===================================") + + // Test specific logger + fmt.Println("\n๐ŸŽฏ Testing specific logger (decorated):") + service.LogToSpecific("console", "This is a console message") + + // Test all loggers (decorated) + service.LogToAll("This message goes to all decorated loggers") + + // Test filtered logging (using map keys) + service.LogToFiltered("Filtered message", func(name string) bool { + return strings.Contains(name, "log") // This won't match our current names + }) + + // Test another filter + fmt.Printf("\n๐Ÿ” Logging to filtered loggers (%s):\n", "starts with 'c'") + service.LogToFiltered("Another filtered message", func(name string) bool { + return strings.HasPrefix(name, "c") + }) + + fmt.Println("\nโœ… Map decoration demo completed successfully!") + fmt.Println("\n๐Ÿ’ก Key insights:") + fmt.Println(" - Map value groups can be decorated just like slices") + fmt.Println(" - Decorators receive map[string]T and return map[string]T") + fmt.Println(" - All loggers were enhanced with [ENHANCED] prefix") + fmt.Println(" - Map keys (names) are preserved through decoration") + fmt.Println(" - Easy filtering and lookup by name") + + return nil + }, + }) +} + +// ===================================================== +// MAIN APPLICATION +// ===================================================== + +func main() { + app := fx.New( + // Provide loggers with names and groups + fx.Provide( + fx.Annotate( + ProvideFileLogger, + fx.ResultTags(`name:"file" group:"loggers"`), + ), + fx.Annotate( + ProvideConsoleLogger, + fx.ResultTags(`name:"console" group:"loggers"`), + ), + fx.Annotate( + ProvideDatabaseLogger, + fx.ResultTags(`name:"database" group:"loggers"`), + ), + ), + + // ๐ŸŽจ DECORATE the logger map - add enhancements + fx.Decorate(DecorateLoggers), + + // Provide the logging service (receives decorated loggers) + fx.Provide(NewLoggingService), + + // Register the demo lifecycle hook + fx.Invoke(RunDecorationDemo), + + // Suppress logs for cleaner demo output + fx.NopLogger, + ) + + fmt.Println("๐ŸŽจ Fx Map Value Groups + Decoration Demo") + fmt.Println("==========================================") + fmt.Println("This demo shows map decoration with our new feature!") + + if err := app.Start(context.Background()); err != nil { + log.Fatal(err) + } + + if err := app.Stop(context.Background()); err != nil { + log.Fatal(err) + } +} \ No newline at end of file diff --git a/demo/map_groups_demo.go b/demo/map_groups_demo.go new file mode 100644 index 000000000..00093df60 --- /dev/null +++ b/demo/map_groups_demo.go @@ -0,0 +1,232 @@ +package main + +import ( + "context" + "fmt" + "log" + + "go.uber.org/fx" +) + +// ===================================================== +// DEMO: Map Value Groups in Fx +// ===================================================== + +// Handler represents a generic handler interface +type Handler interface { + Handle(ctx context.Context, message string) error + Name() string +} + +// EmailHandler handles email notifications +type EmailHandler struct{} + +func (h *EmailHandler) Handle(ctx context.Context, message string) error { + fmt.Printf("๐Ÿ“ง EmailHandler: Sending email - %s\n", message) + return nil +} + +func (h *EmailHandler) Name() string { + return "email" +} + +// SlackHandler handles Slack notifications +type SlackHandler struct{} + +func (h *SlackHandler) Handle(ctx context.Context, message string) error { + fmt.Printf("๐Ÿ’ฌ SlackHandler: Sending Slack message - %s\n", message) + return nil +} + +func (h *SlackHandler) Name() string { + return "slack" +} + +// SMSHandler handles SMS notifications +type SMSHandler struct{} + +func (h *SMSHandler) Handle(ctx context.Context, message string) error { + fmt.Printf("๐Ÿ“ฑ SMSHandler: Sending SMS - %s\n", message) + return nil +} + +func (h *SMSHandler) Name() string { + return "sms" +} + +// ===================================================== +// NOTIFICATION SERVICE USING MAP VALUE GROUPS +// ===================================================== + +type NotificationService struct { + // This is the NEW feature - consuming value groups as map[string]T! + handlerMap map[string]Handler `group:"handlers"` + + // Still works - consuming as slice like before + handlerSlice []Handler `group:"handlers"` +} + +type NotificationParams struct { + fx.In + + // ๐ŸŽฏ NEW: Map consumption - handlers indexed by name + HandlerMap map[string]Handler `group:"handlers"` + + // โœ… EXISTING: Slice consumption still works + HandlerSlice []Handler `group:"handlers"` +} + +func NewNotificationService(params NotificationParams) *NotificationService { + fmt.Println("\n๐Ÿ”ง NotificationService created with:") + fmt.Printf(" Map handlers: %d entries\n", len(params.HandlerMap)) + fmt.Printf(" Slice handlers: %d entries\n", len(params.HandlerSlice)) + + // Show map contents + fmt.Println(" ๐Ÿ“‹ Handler map contents:") + for name, handler := range params.HandlerMap { + fmt.Printf(" - %s: %T\n", name, handler) + } + + return &NotificationService{ + handlerMap: params.HandlerMap, + handlerSlice: params.HandlerSlice, + } +} + +func (s *NotificationService) SendToSpecificHandler(ctx context.Context, handlerName, message string) error { + // ๐ŸŽฏ NEW CAPABILITY: Direct lookup by name using map! + handler, exists := s.handlerMap[handlerName] + if !exists { + return fmt.Errorf("handler %q not found", handlerName) + } + + fmt.Printf("๐Ÿ“ค Sending via specific handler '%s':\n", handlerName) + return handler.Handle(ctx, message) +} + +func (s *NotificationService) BroadcastToAll(ctx context.Context, message string) error { + fmt.Println("๐Ÿ“ข Broadcasting to all handlers:") + for _, handler := range s.handlerSlice { + if err := handler.Handle(ctx, message); err != nil { + return err + } + } + return nil +} + +func (s *NotificationService) ListAvailableHandlers() []string { + // ๐ŸŽฏ NEW: Easy to get all handler names from map keys + names := make([]string, 0, len(s.handlerMap)) + for name := range s.handlerMap { + names = append(names, name) + } + return names +} + +// ===================================================== +// PROVIDER FUNCTIONS +// ===================================================== + +// Provide handlers using BOTH dig.Name() AND dig.Group() +// This was impossible before this PR! + +func ProvideEmailHandler() Handler { + return &EmailHandler{} +} + +func ProvideSlackHandler() Handler { + return &SlackHandler{} +} + +func ProvideSMSHandler() Handler { + return &SMSHandler{} +} + +// ===================================================== +// APPLICATION LIFECYCLE HOOKS +// ===================================================== + +func RunDemo(lc fx.Lifecycle, service *NotificationService) { + lc.Append(fx.Hook{ + OnStart: func(ctx context.Context) error { + fmt.Println("\n๐Ÿš€ Starting Map Value Groups Demo") + fmt.Println("=====================================") + + // Show available handlers + fmt.Println("\n๐Ÿ“‹ Available handlers:") + for _, name := range service.ListAvailableHandlers() { + fmt.Printf(" - %s\n", name) + } + + // Test specific handler lookup (NEW CAPABILITY) + fmt.Println("\n๐ŸŽฏ Testing specific handler lookup:") + if err := service.SendToSpecificHandler(ctx, "email", "Welcome to Fx Map Groups!"); err != nil { + fmt.Printf("โŒ Error: %v\n", err) + } + + if err := service.SendToSpecificHandler(ctx, "slack", "Fx now supports map[string]T groups!"); err != nil { + fmt.Printf("โŒ Error: %v\n", err) + } + + // Test non-existent handler + fmt.Println("\n๐Ÿงช Testing non-existent handler:") + if err := service.SendToSpecificHandler(ctx, "telegram", "This should fail"); err != nil { + fmt.Printf("โœ… Expected error: %v\n", err) + } + + // Test broadcast (existing capability) + fmt.Println("\n๐Ÿ“ข Testing broadcast to all handlers:") + if err := service.BroadcastToAll(ctx, "System maintenance in 10 minutes"); err != nil { + fmt.Printf("โŒ Error: %v\n", err) + } + + fmt.Println("\nโœ… Demo completed successfully!") + return nil + }, + }) +} + +// ===================================================== +// MAIN APPLICATION +// ===================================================== + +func main() { + app := fx.New( + // Provide handlers with BOTH name AND group + fx.Provide( + fx.Annotate( + ProvideEmailHandler, + fx.ResultTags(`name:"email" group:"handlers"`), + ), + fx.Annotate( + ProvideSlackHandler, + fx.ResultTags(`name:"slack" group:"handlers"`), + ), + fx.Annotate( + ProvideSMSHandler, + fx.ResultTags(`name:"sms" group:"handlers"`), + ), + ), + + // Provide the notification service + fx.Provide(NewNotificationService), + + // Register the demo lifecycle hook + fx.Invoke(RunDemo), + + // Suppress logs for cleaner demo output + fx.NopLogger, + ) + + fmt.Println("๐Ÿ”ฅ Fx Map Value Groups Proof of Concept") + fmt.Println("========================================") + fmt.Println("This demo shows the new map[string]T value group feature!") + + if err := app.Start(context.Background()); err != nil { + log.Fatal(err) + } + + if err := app.Stop(context.Background()); err != nil { + log.Fatal(err) + } +} \ No newline at end of file diff --git a/go.mod b/go.mod index af2a10ac7..f4fe644e3 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-20250929003136-0b0022552f09 diff --git a/go.sum b/go.sum index ac0978465..5afb6fcaf 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-20250929003136-0b0022552f09 h1:W7QqWL+tcMYguqdg3YbttY3m20s9d+z1r7lOrL7hzSE= +github.com/jquirke/dig v0.0.0-20250929003136-0b0022552f09/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..77a8c1ae5 --- /dev/null +++ b/map_groups_test.go @@ -0,0 +1,447 @@ +// 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") + }) +} \ No newline at end of file From 7e97659e525ff4dcc6b8cd259760e3fa76f49a4a Mon Sep 17 00:00:00 2001 From: Jeremy Quirke Date: Sun, 28 Sep 2025 20:49:59 -0700 Subject: [PATCH 02/10] Add edge case test for invalid map key types Test that value groups properly reject non-string map keys (using int as example) with the expected error message from dig validation. --- map_groups_test.go | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/map_groups_test.go b/map_groups_test.go index 77a8c1ae5..747502735 100644 --- a/map_groups_test.go +++ b/map_groups_test.go @@ -444,4 +444,28 @@ func TestMapValueGroupsEdgeCases(t *testing.T) { 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") + }) } \ No newline at end of file From c90ed238c200d04d42fef010b1aaf6e76333ab55 Mon Sep 17 00:00:00 2001 From: Jeremy Quirke Date: Sun, 28 Sep 2025 21:58:19 -0700 Subject: [PATCH 03/10] Add module scoping tests for map value groups Test map value groups behavior across fx.Module boundaries: - Map consumption across child and parent modules - Nested module hierarchies - Global visibility of groups across all modules - Name conflict detection across module boundaries Verified behavior is consistent with existing slice value groups where all modules see the global group state. Moved tests to module_test.go where they belong since they specifically test fx.Module behavior. --- map_groups_test.go | 3 +- module_test.go | 209 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 211 insertions(+), 1 deletion(-) diff --git a/map_groups_test.go b/map_groups_test.go index 747502735..fe7641434 100644 --- a/map_groups_test.go +++ b/map_groups_test.go @@ -468,4 +468,5 @@ func TestMapValueGroupsEdgeCases(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "value groups may be consumed as slices or string-keyed maps only") }) -} \ No newline at end of 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") + }) +} From 7c4e9b899805dc25c686a58643d1f79efae000be Mon Sep 17 00:00:00 2001 From: Jeremy Quirke Date: Sun, 28 Sep 2025 22:11:52 -0700 Subject: [PATCH 04/10] Add mixed consumption patterns test Test that the same value group can be consumed as both map and slice in different parts of the same application. This validates an important real-world use case where hybrid consumption patterns are needed. --- map_groups_test.go | 55 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/map_groups_test.go b/map_groups_test.go index fe7641434..13414d9c6 100644 --- a/map_groups_test.go +++ b/map_groups_test.go @@ -468,5 +468,60 @@ func TestMapValueGroupsEdgeCases(t *testing.T) { 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) + }) } From 064a3a5be42c27e43f720a5694d8737c499ae4ad Mon Sep 17 00:00:00 2001 From: Jeremy Quirke Date: Sun, 28 Sep 2025 22:52:40 -0700 Subject: [PATCH 05/10] Add map decoration across modules test This test verifies that map value group decoration chains properly across module boundaries, demonstrating the correct behavior for named value groups as implemented in uber-go/dig#381. --- .claude/settings.local.json | 19 +++++++++ decorate_test.go | 83 +++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 .claude/settings.local.json 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/decorate_test.go b/decorate_test.go index a2399907d..6edb12af4 100644 --- a/decorate_test.go +++ b/decorate_test.go @@ -853,4 +853,87 @@ func TestMapValueGroupsDecoration(t *testing.T) { } 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")) + }) } From 5c0ee5b1dd2b3dbb96ee0e2602c40f10ae354168 Mon Sep 17 00:00:00 2001 From: Jeremy Quirke Date: Mon, 29 Sep 2025 00:47:54 -0700 Subject: [PATCH 06/10] Add soft map group tests for annotation and decoration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add test for soft map group consumption in annotated_test.go - Add test for soft map group decoration in decorate_test.go - Verify soft groups only contain values from executed constructors when consumed as maps - Test both decoration and consumption scenarios with soft map groups ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- annotated_test.go | 30 ++++++++++++++++++++++++++++ decorate_test.go | 50 +++++++++++++++++++++++++++++++++++++++++++++++ go.mod | 2 +- go.sum | 4 ++-- 4 files changed, 83 insertions(+), 3 deletions(-) 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 6edb12af4..2c562af9f 100644 --- a/decorate_test.go +++ b/decorate_test.go @@ -236,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 diff --git a/go.mod b/go.mod index f4fe644e3..c0f20f3d1 100644 --- a/go.mod +++ b/go.mod @@ -17,4 +17,4 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect ) -replace go.uber.org/dig => github.com/jquirke/dig v0.0.0-20250929003136-0b0022552f09 +replace go.uber.org/dig => github.com/jquirke/dig v0.0.0-20250929071627-bc17fef680e7 diff --git a/go.sum b/go.sum index 5afb6fcaf..2b1e777b4 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +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-20250929003136-0b0022552f09 h1:W7QqWL+tcMYguqdg3YbttY3m20s9d+z1r7lOrL7hzSE= -github.com/jquirke/dig v0.0.0-20250929003136-0b0022552f09/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= +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= From 075ba006839bc72e4a96f05f178f3b376bf8328e Mon Sep 17 00:00:00 2001 From: Jeremy Quirke Date: Mon, 29 Sep 2025 00:57:02 -0700 Subject: [PATCH 07/10] Remove demo directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Demos should be placed in proper location, will be fixed later. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- demo/go.mod | 18 --- demo/go.sum | 18 --- demo/map_decoration_demo.go | 258 ------------------------------------ demo/map_groups_demo.go | 232 -------------------------------- 4 files changed, 526 deletions(-) delete mode 100644 demo/go.mod delete mode 100644 demo/go.sum delete mode 100644 demo/map_decoration_demo.go delete mode 100644 demo/map_groups_demo.go diff --git a/demo/go.mod b/demo/go.mod deleted file mode 100644 index a8cf90852..000000000 --- a/demo/go.mod +++ /dev/null @@ -1,18 +0,0 @@ -module fx-map-groups-demo - -go 1.25.1 - -require ( - go.uber.org/dig v1.19.0 - go.uber.org/fx v1.23.0 -) - -require ( - go.uber.org/multierr v1.10.0 // indirect - go.uber.org/zap v1.26.0 // indirect - golang.org/x/sys v0.0.0-20220412211240-33da011f77ad // indirect -) - -replace go.uber.org/fx => ../ - -replace go.uber.org/dig => github.com/jquirke/dig v0.0.0-20250929003136-0b0022552f09 diff --git a/demo/go.sum b/demo/go.sum deleted file mode 100644 index 37892e125..000000000 --- a/demo/go.sum +++ /dev/null @@ -1,18 +0,0 @@ -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-20250929003136-0b0022552f09 h1:W7QqWL+tcMYguqdg3YbttY3m20s9d+z1r7lOrL7hzSE= -github.com/jquirke/dig v0.0.0-20250929003136-0b0022552f09/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/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -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= -go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= -go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= -golang.org/x/sys v0.0.0-20220412211240-33da011f77ad h1:ntjMns5wyP/fN65tdBD4g8J5w8n015+iIIs9rtjXkY0= -golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/demo/map_decoration_demo.go b/demo/map_decoration_demo.go deleted file mode 100644 index 3a1189e4e..000000000 --- a/demo/map_decoration_demo.go +++ /dev/null @@ -1,258 +0,0 @@ -package main - -import ( - "context" - "fmt" - "log" - "strings" - - "go.uber.org/fx" -) - -// ===================================================== -// DEMO: Map Value Groups + Decoration in Fx -// ===================================================== - -// Logger interface for different log outputs -type Logger interface { - Log(message string) - Name() string -} - -// FileLogger logs to files -type FileLogger struct{ filename string } - -func (l *FileLogger) Log(message string) { - fmt.Printf("๐Ÿ“ FileLogger[%s]: %s\n", l.filename, message) -} - -func (l *FileLogger) Name() string { - return "file" -} - -// ConsoleLogger logs to console -type ConsoleLogger struct{} - -func (l *ConsoleLogger) Log(message string) { - fmt.Printf("๐Ÿ–ฅ๏ธ ConsoleLogger: %s\n", message) -} - -func (l *ConsoleLogger) Name() string { - return "console" -} - -// DatabaseLogger logs to database -type DatabaseLogger struct{} - -func (l *DatabaseLogger) Log(message string) { - fmt.Printf("๐Ÿ—„๏ธ DatabaseLogger: %s\n", message) -} - -func (l *DatabaseLogger) Name() string { - return "database" -} - -// ===================================================== -// PROVIDERS -// ===================================================== - -func ProvideFileLogger() Logger { - return &FileLogger{filename: "app.log"} -} - -func ProvideConsoleLogger() Logger { - return &ConsoleLogger{} -} - -func ProvideDatabaseLogger() Logger { - return &DatabaseLogger{} -} - -// ===================================================== -// MAP DECORATION EXAMPLE -// ===================================================== - -type LoggerDecorationParams struct { - fx.In - // Input: map of loggers by name - Loggers map[string]Logger `group:"loggers"` -} - -type LoggerDecorationResult struct { - fx.Out - // Output: enhanced map of loggers - Loggers map[string]Logger `group:"loggers"` -} - -// DecorateLoggers demonstrates map decoration - add prefix to all loggers -func DecorateLoggers(params LoggerDecorationParams) LoggerDecorationResult { - fmt.Println("\n๐ŸŽจ Decorating loggers with [ENHANCED] prefix...") - - enhancedLoggers := make(map[string]Logger) - - for name, logger := range params.Loggers { - // Wrap each logger with enhancement - enhancedLoggers[name] = &EnhancedLogger{ - wrapped: logger, - prefix: "[ENHANCED]", - } - fmt.Printf(" โœจ Enhanced logger: %s\n", name) - } - - return LoggerDecorationResult{Loggers: enhancedLoggers} -} - -// EnhancedLogger wraps another logger with a prefix -type EnhancedLogger struct { - wrapped Logger - prefix string -} - -func (l *EnhancedLogger) Log(message string) { - l.wrapped.Log(fmt.Sprintf("%s %s", l.prefix, message)) -} - -func (l *EnhancedLogger) Name() string { - return l.wrapped.Name() -} - -// ===================================================== -// LOGGING SERVICE USING DECORATED MAP -// ===================================================== - -type LoggingServiceParams struct { - fx.In - - // ๐ŸŽฏ This map will contain DECORATED loggers! - LoggerMap map[string]Logger `group:"loggers"` -} - -type LoggingService struct { - loggers map[string]Logger -} - -func NewLoggingService(params LoggingServiceParams) *LoggingService { - fmt.Printf("\n๐Ÿ”ง LoggingService created with %d decorated loggers\n", len(params.LoggerMap)) - - // Show what we received - for name := range params.LoggerMap { - fmt.Printf(" ๐Ÿ“„ Logger available: %s\n", name) - } - - return &LoggingService{loggers: params.LoggerMap} -} - -func (s *LoggingService) LogToSpecific(loggerName, message string) error { - logger, exists := s.loggers[loggerName] - if !exists { - return fmt.Errorf("logger %q not found", loggerName) - } - - logger.Log(message) - return nil -} - -func (s *LoggingService) LogToAll(message string) { - fmt.Println("\n๐Ÿ“ข Broadcasting to all loggers:") - for _, logger := range s.loggers { - logger.Log(message) - } -} - -func (s *LoggingService) LogToFiltered(message string, filter func(name string) bool) { - fmt.Printf("\n๐Ÿ” Logging to filtered loggers (%s):\n", "contains 'log'") - for name, logger := range s.loggers { - if filter(name) { - logger.Log(message) - } - } -} - -// ===================================================== -// APPLICATION LIFECYCLE -// ===================================================== - -func RunDecorationDemo(lc fx.Lifecycle, service *LoggingService) { - lc.Append(fx.Hook{ - OnStart: func(ctx context.Context) error { - fmt.Println("\n๐Ÿš€ Starting Map Decoration Demo") - fmt.Println("===================================") - - // Test specific logger - fmt.Println("\n๐ŸŽฏ Testing specific logger (decorated):") - service.LogToSpecific("console", "This is a console message") - - // Test all loggers (decorated) - service.LogToAll("This message goes to all decorated loggers") - - // Test filtered logging (using map keys) - service.LogToFiltered("Filtered message", func(name string) bool { - return strings.Contains(name, "log") // This won't match our current names - }) - - // Test another filter - fmt.Printf("\n๐Ÿ” Logging to filtered loggers (%s):\n", "starts with 'c'") - service.LogToFiltered("Another filtered message", func(name string) bool { - return strings.HasPrefix(name, "c") - }) - - fmt.Println("\nโœ… Map decoration demo completed successfully!") - fmt.Println("\n๐Ÿ’ก Key insights:") - fmt.Println(" - Map value groups can be decorated just like slices") - fmt.Println(" - Decorators receive map[string]T and return map[string]T") - fmt.Println(" - All loggers were enhanced with [ENHANCED] prefix") - fmt.Println(" - Map keys (names) are preserved through decoration") - fmt.Println(" - Easy filtering and lookup by name") - - return nil - }, - }) -} - -// ===================================================== -// MAIN APPLICATION -// ===================================================== - -func main() { - app := fx.New( - // Provide loggers with names and groups - fx.Provide( - fx.Annotate( - ProvideFileLogger, - fx.ResultTags(`name:"file" group:"loggers"`), - ), - fx.Annotate( - ProvideConsoleLogger, - fx.ResultTags(`name:"console" group:"loggers"`), - ), - fx.Annotate( - ProvideDatabaseLogger, - fx.ResultTags(`name:"database" group:"loggers"`), - ), - ), - - // ๐ŸŽจ DECORATE the logger map - add enhancements - fx.Decorate(DecorateLoggers), - - // Provide the logging service (receives decorated loggers) - fx.Provide(NewLoggingService), - - // Register the demo lifecycle hook - fx.Invoke(RunDecorationDemo), - - // Suppress logs for cleaner demo output - fx.NopLogger, - ) - - fmt.Println("๐ŸŽจ Fx Map Value Groups + Decoration Demo") - fmt.Println("==========================================") - fmt.Println("This demo shows map decoration with our new feature!") - - if err := app.Start(context.Background()); err != nil { - log.Fatal(err) - } - - if err := app.Stop(context.Background()); err != nil { - log.Fatal(err) - } -} \ No newline at end of file diff --git a/demo/map_groups_demo.go b/demo/map_groups_demo.go deleted file mode 100644 index 00093df60..000000000 --- a/demo/map_groups_demo.go +++ /dev/null @@ -1,232 +0,0 @@ -package main - -import ( - "context" - "fmt" - "log" - - "go.uber.org/fx" -) - -// ===================================================== -// DEMO: Map Value Groups in Fx -// ===================================================== - -// Handler represents a generic handler interface -type Handler interface { - Handle(ctx context.Context, message string) error - Name() string -} - -// EmailHandler handles email notifications -type EmailHandler struct{} - -func (h *EmailHandler) Handle(ctx context.Context, message string) error { - fmt.Printf("๐Ÿ“ง EmailHandler: Sending email - %s\n", message) - return nil -} - -func (h *EmailHandler) Name() string { - return "email" -} - -// SlackHandler handles Slack notifications -type SlackHandler struct{} - -func (h *SlackHandler) Handle(ctx context.Context, message string) error { - fmt.Printf("๐Ÿ’ฌ SlackHandler: Sending Slack message - %s\n", message) - return nil -} - -func (h *SlackHandler) Name() string { - return "slack" -} - -// SMSHandler handles SMS notifications -type SMSHandler struct{} - -func (h *SMSHandler) Handle(ctx context.Context, message string) error { - fmt.Printf("๐Ÿ“ฑ SMSHandler: Sending SMS - %s\n", message) - return nil -} - -func (h *SMSHandler) Name() string { - return "sms" -} - -// ===================================================== -// NOTIFICATION SERVICE USING MAP VALUE GROUPS -// ===================================================== - -type NotificationService struct { - // This is the NEW feature - consuming value groups as map[string]T! - handlerMap map[string]Handler `group:"handlers"` - - // Still works - consuming as slice like before - handlerSlice []Handler `group:"handlers"` -} - -type NotificationParams struct { - fx.In - - // ๐ŸŽฏ NEW: Map consumption - handlers indexed by name - HandlerMap map[string]Handler `group:"handlers"` - - // โœ… EXISTING: Slice consumption still works - HandlerSlice []Handler `group:"handlers"` -} - -func NewNotificationService(params NotificationParams) *NotificationService { - fmt.Println("\n๐Ÿ”ง NotificationService created with:") - fmt.Printf(" Map handlers: %d entries\n", len(params.HandlerMap)) - fmt.Printf(" Slice handlers: %d entries\n", len(params.HandlerSlice)) - - // Show map contents - fmt.Println(" ๐Ÿ“‹ Handler map contents:") - for name, handler := range params.HandlerMap { - fmt.Printf(" - %s: %T\n", name, handler) - } - - return &NotificationService{ - handlerMap: params.HandlerMap, - handlerSlice: params.HandlerSlice, - } -} - -func (s *NotificationService) SendToSpecificHandler(ctx context.Context, handlerName, message string) error { - // ๐ŸŽฏ NEW CAPABILITY: Direct lookup by name using map! - handler, exists := s.handlerMap[handlerName] - if !exists { - return fmt.Errorf("handler %q not found", handlerName) - } - - fmt.Printf("๐Ÿ“ค Sending via specific handler '%s':\n", handlerName) - return handler.Handle(ctx, message) -} - -func (s *NotificationService) BroadcastToAll(ctx context.Context, message string) error { - fmt.Println("๐Ÿ“ข Broadcasting to all handlers:") - for _, handler := range s.handlerSlice { - if err := handler.Handle(ctx, message); err != nil { - return err - } - } - return nil -} - -func (s *NotificationService) ListAvailableHandlers() []string { - // ๐ŸŽฏ NEW: Easy to get all handler names from map keys - names := make([]string, 0, len(s.handlerMap)) - for name := range s.handlerMap { - names = append(names, name) - } - return names -} - -// ===================================================== -// PROVIDER FUNCTIONS -// ===================================================== - -// Provide handlers using BOTH dig.Name() AND dig.Group() -// This was impossible before this PR! - -func ProvideEmailHandler() Handler { - return &EmailHandler{} -} - -func ProvideSlackHandler() Handler { - return &SlackHandler{} -} - -func ProvideSMSHandler() Handler { - return &SMSHandler{} -} - -// ===================================================== -// APPLICATION LIFECYCLE HOOKS -// ===================================================== - -func RunDemo(lc fx.Lifecycle, service *NotificationService) { - lc.Append(fx.Hook{ - OnStart: func(ctx context.Context) error { - fmt.Println("\n๐Ÿš€ Starting Map Value Groups Demo") - fmt.Println("=====================================") - - // Show available handlers - fmt.Println("\n๐Ÿ“‹ Available handlers:") - for _, name := range service.ListAvailableHandlers() { - fmt.Printf(" - %s\n", name) - } - - // Test specific handler lookup (NEW CAPABILITY) - fmt.Println("\n๐ŸŽฏ Testing specific handler lookup:") - if err := service.SendToSpecificHandler(ctx, "email", "Welcome to Fx Map Groups!"); err != nil { - fmt.Printf("โŒ Error: %v\n", err) - } - - if err := service.SendToSpecificHandler(ctx, "slack", "Fx now supports map[string]T groups!"); err != nil { - fmt.Printf("โŒ Error: %v\n", err) - } - - // Test non-existent handler - fmt.Println("\n๐Ÿงช Testing non-existent handler:") - if err := service.SendToSpecificHandler(ctx, "telegram", "This should fail"); err != nil { - fmt.Printf("โœ… Expected error: %v\n", err) - } - - // Test broadcast (existing capability) - fmt.Println("\n๐Ÿ“ข Testing broadcast to all handlers:") - if err := service.BroadcastToAll(ctx, "System maintenance in 10 minutes"); err != nil { - fmt.Printf("โŒ Error: %v\n", err) - } - - fmt.Println("\nโœ… Demo completed successfully!") - return nil - }, - }) -} - -// ===================================================== -// MAIN APPLICATION -// ===================================================== - -func main() { - app := fx.New( - // Provide handlers with BOTH name AND group - fx.Provide( - fx.Annotate( - ProvideEmailHandler, - fx.ResultTags(`name:"email" group:"handlers"`), - ), - fx.Annotate( - ProvideSlackHandler, - fx.ResultTags(`name:"slack" group:"handlers"`), - ), - fx.Annotate( - ProvideSMSHandler, - fx.ResultTags(`name:"sms" group:"handlers"`), - ), - ), - - // Provide the notification service - fx.Provide(NewNotificationService), - - // Register the demo lifecycle hook - fx.Invoke(RunDemo), - - // Suppress logs for cleaner demo output - fx.NopLogger, - ) - - fmt.Println("๐Ÿ”ฅ Fx Map Value Groups Proof of Concept") - fmt.Println("========================================") - fmt.Println("This demo shows the new map[string]T value group feature!") - - if err := app.Start(context.Background()); err != nil { - log.Fatal(err) - } - - if err := app.Stop(context.Background()); err != nil { - log.Fatal(err) - } -} \ No newline at end of file From 1408c238a82ddcd4bc8ca62cb887737320616a4c Mon Sep 17 00:00:00 2001 From: Jeremy Quirke Date: Mon, 29 Sep 2025 01:34:39 -0700 Subject: [PATCH 08/10] Add comprehensive documentation for named value groups and map consumption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Named Value Groups and Map Consumption section to doc.go - Document providing values with both name and group tags - Show multiple consumption patterns: map, slice, and individual access - Include practical examples with fx.Annotate and struct tags - Explain constraints for map consumption (string keys, all values must have names) ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- doc.go | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/doc.go b/doc.go index 4ee2227ac..0d70601be 100644 --- a/doc.go +++ b/doc.go @@ -247,6 +247,62 @@ // 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 tags: +// +// type NamedHandlerResult struct { +// fx.Out +// +// AdminHandler Handler `name:"admin" group:"server"` +// UserHandler Handler `name:"user" group:"server"` +// } +// +// Or with fx.Annotate: +// +// fx.Provide( +// fx.Annotate(NewAdminHandler, fx.ResultTags(`name:"admin" group:"server"`)), +// fx.Annotate(NewUserHandler, fx.ResultTags(`name:"user" group:"server"`)), +// ) +// +// 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, From d781ec78700d57c46c7bbfbfbaac6785585cbf88 Mon Sep 17 00:00:00 2001 From: Jeremy Quirke Date: Mon, 29 Sep 2025 08:14:12 -0700 Subject: [PATCH 09/10] Add comprehensive map value groups demo and documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add demo code in docs/ex/value-groups/maps/ with handlers, feeding, consuming examples - Create documentation in docs/src/value-groups/maps/ explaining map consumption - Update mkdocs.yml to include new map documentation pages - Add dig replace directive to docs/go.mod for map groups support - Include decoration restrictions and error conditions in documentation - Provide working tests demonstrating map vs slice consumption ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- docs/ex/value-groups/maps/consume.go | 79 +++++++++++++++++++++ docs/ex/value-groups/maps/example.go | 47 +++++++++++++ docs/ex/value-groups/maps/feed.go | 51 ++++++++++++++ docs/ex/value-groups/maps/handler.go | 65 +++++++++++++++++ docs/ex/value-groups/maps/maps_test.go | 96 +++++++++++++++++++++++++ docs/go.mod | 2 + docs/go.sum | 4 +- docs/mkdocs.yml | 3 + docs/src/value-groups/maps/consume.md | 97 ++++++++++++++++++++++++++ docs/src/value-groups/maps/feed.md | 79 +++++++++++++++++++++ 10 files changed, 521 insertions(+), 2 deletions(-) create mode 100644 docs/ex/value-groups/maps/consume.go create mode 100644 docs/ex/value-groups/maps/example.go create mode 100644 docs/ex/value-groups/maps/feed.go create mode 100644 docs/ex/value-groups/maps/handler.go create mode 100644 docs/ex/value-groups/maps/maps_test.go create mode 100644 docs/src/value-groups/maps/consume.md create mode 100644 docs/src/value-groups/maps/feed.md 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 From 273272ed2b327bb37105f4e5be7f42dc2a5e8809 Mon Sep 17 00:00:00 2001 From: Jeremy Quirke Date: Wed, 8 Oct 2025 21:24:44 -0700 Subject: [PATCH 10/10] Fix documentation and add test for fx.Out with name+group annotations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update annotated.go docs to remove outdated mutual exclusivity restriction - Enhance doc.go with clearer examples showing both fx.Out and fx.ResultTags approaches - Add comprehensive test demonstrating fx.Out struct with both name and group tags This confirms that fx.Out structs fully support combining name and group annotations, which was already working but not properly documented or tested. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- annotated.go | 6 +++-- doc.go | 7 ++++-- map_groups_test.go | 63 ++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 70 insertions(+), 6 deletions(-) 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/doc.go b/doc.go index 0d70601be..f1d98387e 100644 --- a/doc.go +++ b/doc.go @@ -254,7 +254,8 @@ // 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 tags: +// 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 @@ -263,13 +264,15 @@ // UserHandler Handler `name:"user" group:"server"` // } // -// Or with fx.Annotate: +// 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 { diff --git a/map_groups_test.go b/map_groups_test.go index 13414d9c6..508ac5a80 100644 --- a/map_groups_test.go +++ b/map_groups_test.go @@ -419,7 +419,7 @@ func TestMapValueGroupsEdgeCases(t *testing.T) { type MixedParams struct { fx.In Services map[string]testSimpleService `group:"mixed"` - ServiceSlice []testSimpleService `group:"mixed"` + ServiceSlice []testSimpleService `group:"mixed"` } var params MixedParams @@ -523,5 +523,64 @@ func TestMapValueGroupsEdgeCases(t *testing.T) { } 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"]) + }) +}