diff --git a/Makefile b/Makefile index a66a33a..e11de90 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,15 @@ +coverage=coverage.txt + all: lint test bench test: - go test -race -timeout 10s -coverprofile=coverage.txt -covermode=atomic ./... + go test -race -timeout 10s -coverprofile=$(coverage) -covermode=atomic ./... test/%: - go test -race -timeout 10s -coverprofile=coverage.txt -covermode=atomic -run="${*}" ./... + go test -race -timeout 10s -coverprofile=$(coverage) -covermode=atomic -run="${*}" ./... + +coverage/show: test + go tool cover -html=$(coverage) fmt: gofmt -s -w . diff --git a/errutil/error.go b/errutil/error.go index a7c315c..9e0b29c 100644 --- a/errutil/error.go +++ b/errutil/error.go @@ -4,6 +4,11 @@ // Utilities include: // // - An error type that makes it easy to work with const error sentinels. +// - An easy way to wrap a list of errors together. +// - An easy way to reduce a list of errors. +// +// Flexible enough that you can do your own wrapping/merging logic +// but in a functional/simple way. package errutil import "errors" @@ -14,6 +19,9 @@ import "errors" // as it's base type. type Error string +// Reducer reduces 2 errors into one +type Reducer func(error, error) error + // Error return a string representation of the error. func (e Error) Error() string { return string(e) @@ -40,6 +48,24 @@ func Chain(errs ...error) error { } } +// Reduce will reduce all errors to a single one using the +// provided reduce function. +// +// If errs is empty it returns nil, if errs has a single err +// (len(errs) == 1) it will return the err itself. +// +// Nil errors on the errs args will be filtered out initially, +// before reducing, so you can expect errors passed to the reducer +// to be always non-nil. +// +// But if the reducer function itself returns nil, then the returned nil +// won't be filtered and will be passed as an argument on the next +// reducing step. +func Reduce(r Reducer, errs ...error) error { + errs = removeNils(errs) + return reduce(r, errs...) +} + type errorChain struct { head error tail error @@ -74,3 +100,15 @@ func removeNils(errs []error) []error { } return res } + +func reduce(r Reducer, errs ...error) error { + if len(errs) == 0 { + return nil + } + if len(errs) == 1 { + return errs[0] + } + err1, err2 := errs[0], errs[1] + err := r(err1, err2) + return reduce(r, append([]error{err}, errs[2:]...)...) +} diff --git a/errutil/error_example_test.go b/errutil/error_example_test.go index 98f3f95..0c2950a 100644 --- a/errutil/error_example_test.go +++ b/errutil/error_example_test.go @@ -39,9 +39,36 @@ func ExampleChain() { fmt.Println(errors.Is(err, layer1Err)) fmt.Println(errors.Is(err, layer2Err)) fmt.Println(errors.Is(err, layer3Err)) + fmt.Println(err) // Output: // true // true // true + // layer1Err: layer2Err: layer3Err +} + +func ExampleReduce() { + // call multiple functions that may return an error + // but none of them should interrupt overall computation + var i int + someFunc := func() error { + i++ + return fmt.Errorf("error %d", i) + } + + var errs []error + + errs = append(errs, someFunc()) + errs = append(errs, someFunc()) + errs = append(errs, someFunc()) + + err := errutil.Reduce(func(err1, err2 error) error { + return fmt.Errorf("%v,%v", err1, err2) + }, errs...) + + fmt.Println(err) + + // Output: + // error 1,error 2,error 3 } diff --git a/errutil/error_test.go b/errutil/error_test.go index 64306db..975b932 100644 --- a/errutil/error_test.go +++ b/errutil/error_test.go @@ -251,9 +251,228 @@ func TestErrorChainRespectIsMethodOfChainedErrors(t *testing.T) { } } -// To test the Is method the error must not be comparable. +func TestErrorReducing(t *testing.T) { + type testcase struct { + name string + errs []error + reduce errutil.Reducer + want string + wantNil bool + } + + mergeWithComma := func(err1, err2 error) error { + return fmt.Errorf("%v,%v", err1, err2) + } + + tests := []testcase{ + { + name: "reducing empty err list wont call reducer and returns nil", + errs: []error{}, + reduce: func(err1, err2 error) error { + panic("unreachable") + }, + wantNil: true, + }, + { + name: "reducing one error wont call reducer and returns error", + errs: []error{errors.New("one")}, + reduce: func(err1, err2 error) error { + panic("should not be called") + }, + want: "one", + }, + { + name: "reducing two errors", + errs: []error{errors.New("one"), errors.New("two")}, + reduce: mergeWithComma, + want: "one,two", + }, + { + name: "reducing three errors", + errs: []error{ + errors.New("one"), + errors.New("two"), + errors.New("three"), + }, + reduce: mergeWithComma, + want: "one,two,three", + }, + { + name: "filtering just first err of 3", + errs: []error{ + errors.New("one"), + errors.New("two"), + errors.New("three"), + }, + reduce: func(err1, err2 error) error { + return err1 + }, + want: "one", + }, + { + name: "filtering just first err of single err", + errs: []error{errors.New("one")}, + reduce: func(err1, err2 error) error { + return err1 + }, + want: "one", + }, + { + name: "filtering just second err", + errs: []error{ + errors.New("one"), + errors.New("two"), + errors.New("three"), + }, + reduce: func(err1, err2 error) error { + return err2 + }, + want: "three", + }, + { + name: "reduces 3 errs to nil", + errs: []error{ + errors.New("one"), + errors.New("two"), + errors.New("three"), + }, + reduce: func(err1, err2 error) error { + return nil + }, + wantNil: true, + }, + { + name: "reduces 2 errs to nil", + errs: []error{ + errors.New("one"), + errors.New("two"), + }, + reduce: func(err1, err2 error) error { + return nil + }, + wantNil: true, + }, + { + name: "first is nil", + errs: []error{ + nil, + errors.New("error 2"), + errors.New("error 3"), + }, + reduce: mergeWithComma, + want: "error 2,error 3", + }, + { + name: "second is nil", + errs: []error{ + errors.New("error 1"), + nil, + errors.New("error 3"), + }, + reduce: mergeWithComma, + want: "error 1,error 3", + }, + { + name: "third is nil", + errs: []error{ + errors.New("error 1"), + errors.New("error 2"), + nil, + }, + reduce: mergeWithComma, + want: "error 1,error 2", + }, + { + name: "multiple nils interleaved", + errs: []error{ + nil, + nil, + nil, + errors.New("error 1"), + nil, + nil, + errors.New("error 2"), + nil, + nil, + }, + reduce: mergeWithComma, + want: "error 1,error 2", + }, + { + name: "first err among nils", + errs: []error{ + errors.New("error 1"), + nil, + nil, + nil, + }, + reduce: mergeWithComma, + want: "error 1", + }, + { + name: "last err among nils", + errs: []error{ + nil, + nil, + nil, + errors.New("error 1"), + }, + reduce: mergeWithComma, + want: "error 1", + }, + { + name: "reduces list with nils to nil", + errs: []error{ + nil, + nil, + nil, + }, + reduce: mergeWithComma, + wantNil: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + g := errutil.Reduce(test.reduce, test.errs...) + + if test.wantNil { + if g == nil { + return + } + t.Fatalf( + "errutil.Reduce(%v)=%q; want nil", + test.errs, + g, + ) + } + + if g == nil { + t.Fatalf( + "errutil.Reduce(%v)=nil; want %q", + test.errs, + test.want, + ) + } + + got := g.Error() + want := test.want + + if got != want { + t.Fatalf( + "errutil.Reduce(%v)=%q; want=%q", + test.errs, + got, + want, + ) + } + }) + } +} + +// To test the Is method the error base type must not be comparable. // If it is comparable, Go always just compares it, the Is method -// is just a fallback, not an override of actual behavior. +// is just a fallback, not an override of actual comparison behavior. type errorThatNeverIs []string func (e errorThatNeverIs) Is(err error) bool {