Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: [PoC] fx.Evaluate #1259

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions app.go
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,23 @@
return app
}

// At this point, we can run the fx.Evaluates (if any).
// As long as there's at least one evaluate per iteration,
// we'll have to keep unwinding.
//
// Keep evaluating until there are no more evaluates to run.
for app.root.evaluateAll() > 0 {
// TODO: is communicating the number of evalutes the best way?
if app.err != nil {
return app
}

Check warning on line 516 in app.go

View check run for this annotation

Codecov / codecov/patch

app.go#L515-L516

Added lines #L515 - L516 were not covered by tests

// TODO: fx.Module inside evaluates needs to build subscopes.
app.root.provideAll()
app.err = multierr.Append(app.err, app.root.decorateAll())
// TODO: fx.WithLogger allowed inside an evaluate?
}

if err := app.root.invokeAll(); err != nil {
app.err = err

Expand Down
153 changes: 153 additions & 0 deletions evaluate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
// Copyright (c) 2024 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

import (
"fmt"
"reflect"
"strings"

"go.uber.org/fx/internal/fxreflect"
)

// Evaluate specifies one or more evaluation functions.
// These are functions that accept dependencies from the graph
// and return an fx.Option.
// They may have the following signatures:
//
// func(...) fx.Option
// func(...) (fx.Option, error)
//
// These functions are run after provides and decorates.
// The resulting options are applied to the graph,
// and may introduce new provides, invokes, decorates, or evaluates.
//
// The effect of this is that parts of the graph can be dynamically generated
// based on dependency values.
//
// For example, a function with a dependency on a configuration struct
// could conditionally provide different implementations based on the value.
//
// fx.Evaluate(func(cfg *Config) fx.Option {
// if cfg.Environment == "production" {
// return fx.Provide(func(*sql.DB) Repository {
// return &sqlRepository{db: db}
// }),
// } else {
// return fx.Provide(func() Repository {
// return &memoryRepository{}
// })
// }
// })
//
// This is different from a normal provide that inspects the configuration
// because the dependency on '*sql.DB' is completely absent in the graph
// if the configuration is not "production".
func Evaluate(fns ...any) Option {
return evaluateOption{
Targets: fns,
Stack: fxreflect.CallerStack(1, 0),
}
}

type evaluateOption struct {
Targets []any
Stack fxreflect.Stack
}

func (o evaluateOption) apply(mod *module) {
for _, target := range o.Targets {
mod.evaluates = append(mod.evaluates, evaluate{
Target: target,
Stack: o.Stack,
})
}
}

func (o evaluateOption) String() string {
items := make([]string, len(o.Targets))
for i, target := range o.Targets {
items[i] = fxreflect.FuncName(target)
}
return fmt.Sprintf("fx.Evaluate(%s)", strings.Join(items, ", "))

Check warning on line 90 in evaluate.go

View check run for this annotation

Codecov / codecov/patch

evaluate.go#L85-L90

Added lines #L85 - L90 were not covered by tests
}

type evaluate struct {
Target any
Stack fxreflect.Stack
}

func runEvaluate(m *module, e evaluate) (err error) {
target := e.Target
defer func() {
if err != nil {
err = fmt.Errorf("fx.Evaluate(%v) from:\n%+vFailed: %w", target, e.Stack, err)
}

Check warning on line 103 in evaluate.go

View check run for this annotation

Codecov / codecov/patch

evaluate.go#L102-L103

Added lines #L102 - L103 were not covered by tests
}()

// target is a function returning (Option, error).
// Use reflection to build a function with the same parameters,
// and invoke that in the container.
targetV := reflect.ValueOf(target)
targetT := targetV.Type()
inTypes := make([]reflect.Type, targetT.NumIn())
for i := range targetT.NumIn() {
inTypes[i] = targetT.In(i)
}
outTypes := []reflect.Type{reflect.TypeOf((*error)(nil)).Elem()}

// TODO: better way to extract information from the container
var opt Option
invokeFn := reflect.MakeFunc(
reflect.FuncOf(inTypes, outTypes, false),
func(args []reflect.Value) []reflect.Value {
out := targetV.Call(args)
switch len(out) {
case 2:
if err, _ := out[1].Interface().(error); err != nil {
return []reflect.Value{reflect.ValueOf(err)}
}

Check warning on line 127 in evaluate.go

View check run for this annotation

Codecov / codecov/patch

evaluate.go#L124-L127

Added lines #L124 - L127 were not covered by tests

fallthrough

Check warning on line 129 in evaluate.go

View check run for this annotation

Codecov / codecov/patch

evaluate.go#L129

Added line #L129 was not covered by tests
case 1:
opt, _ = out[0].Interface().(Option)

default:
panic("TODO: validation")

Check warning on line 134 in evaluate.go

View check run for this annotation

Codecov / codecov/patch

evaluate.go#L133-L134

Added lines #L133 - L134 were not covered by tests
}

return []reflect.Value{
reflect.Zero(reflect.TypeOf((*error)(nil)).Elem()),
}
},
).Interface()
if err := m.scope.Invoke(invokeFn); err != nil {
return err
}

Check warning on line 144 in evaluate.go

View check run for this annotation

Codecov / codecov/patch

evaluate.go#L143-L144

Added lines #L143 - L144 were not covered by tests

if opt == nil {
// Assume no-op.
return nil
}

Check warning on line 149 in evaluate.go

View check run for this annotation

Codecov / codecov/patch

evaluate.go#L147-L149

Added lines #L147 - L149 were not covered by tests

opt.apply(m)
return nil
}
114 changes: 114 additions & 0 deletions evaluate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// Copyright (c) 2025 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 (
"bytes"
"io"
"testing"

"github.com/stretchr/testify/assert"
"go.uber.org/fx"
"go.uber.org/fx/fxtest"
)

func TestEvaluate(t *testing.T) {
t.Run("ProvidesOptions", func(t *testing.T) {
type t1 struct{}
type t2 struct{}

var evaluated, provided, invoked bool
app := fxtest.New(t,
fx.Evaluate(func() fx.Option {
evaluated = true
return fx.Provide(func() t1 {
provided = true
return t1{}
})
}),
fx.Provide(func(t1) t2 { return t2{} }),
fx.Invoke(func(t2) {
invoked = true
}),
)
defer app.RequireStart().RequireStop()

assert.True(t, evaluated, "Evaluated function was not called")
assert.True(t, provided, "Provided function was not called")
assert.True(t, invoked, "Invoked function was not called")
})

t.Run("OptionalDependency", func(t *testing.T) {
type Config struct{ Dev bool }

newBufWriter := func(b *bytes.Buffer) io.Writer {
return b
}

newDiscardWriter := func() io.Writer {
return io.Discard
}

newWriter := func(cfg Config) fx.Option {
if cfg.Dev {
return fx.Provide(newDiscardWriter)
}

return fx.Provide(newBufWriter)
}

t.Run("NoDependency", func(t *testing.T) {
var got io.Writer
app := fxtest.New(t,
fx.Evaluate(newWriter),
fx.Provide(
func() *bytes.Buffer {
t.Errorf("unexpected call to *bytes.Buffer")
return nil
},
),
fx.Supply(Config{Dev: true}),
fx.Populate(&got),
)
defer app.RequireStart().RequireStop()

assert.NotNil(t, got)
_, _ = io.WriteString(got, "hello")
})

t.Run("WithDependency", func(t *testing.T) {
var (
buf bytes.Buffer
got io.Writer
)
app := fxtest.New(t,
fx.Evaluate(newWriter),
fx.Supply(&buf, Config{Dev: false}),
fx.Populate(&got),
)
defer app.RequireStart().RequireStop()

assert.NotNil(t, got)
_, _ = io.WriteString(got, "hello")
assert.Equal(t, "hello", buf.String())
})
})
}
26 changes: 26 additions & 0 deletions module.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@
provides []provide
invokes []invoke
decorators []decorator
evaluates []evaluate
modules []*module
app *App
log fxevent.Logger
Expand Down Expand Up @@ -174,6 +175,7 @@
for _, p := range m.provides {
m.provide(p)
}
m.provides = nil

for _, m := range m.modules {
m.provideAll()
Expand Down Expand Up @@ -264,6 +266,7 @@
}
}
m.fallbackLogger = nil
m.logConstructor = nil
} else if m.parent != nil {
m.log = m.parent.log
}
Expand Down Expand Up @@ -308,6 +311,7 @@
return err
}
}
m.invokes = nil

return nil
}
Expand All @@ -334,6 +338,7 @@
return err
}
}
m.decorators = nil

for _, m := range m.modules {
if err := m.decorateAll(); err != nil {
Expand Down Expand Up @@ -405,3 +410,24 @@
})
return err
}

func (m *module) evaluateAll() (count int) {
for _, e := range m.evaluates {
m.evaluate(e)
count++
}
m.evaluates = nil

for _, m := range m.modules {
count += m.evaluateAll()
}

return count
}

func (m *module) evaluate(e evaluate) {
// TODO: events
if err := runEvaluate(m, e); err != nil {
m.app.err = err
}

Check warning on line 432 in module.go

View check run for this annotation

Codecov / codecov/patch

module.go#L431-L432

Added lines #L431 - L432 were not covered by tests
}
Loading