Skip to content

Commit

Permalink
feat: errutil.Reduce (#18)
Browse files Browse the repository at this point in the history
* test: add initial spec and tests for errutil.Reduce

* feat: implement errutil.Reduce

Implementation is not most efficient, naive recursive

* docs: improve Reduce docs

* feat: add initial Merge function

* feat: add nil filtering on merge function

* example: add merge example

* fix: remove non-sense comments

* fix: remove unnecessary return statement

* docs: improve docs

* feat: remove Merge and add nil filtering to reduce

* fix: allow reducer to return/receive nils

* docs: improve

* docs: improve docs
  • Loading branch information
katcipis authored Dec 12, 2021
1 parent 0ecd556 commit 60e43ab
Show file tree
Hide file tree
Showing 4 changed files with 293 additions and 4 deletions.
9 changes: 7 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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 .
Expand Down
38 changes: 38 additions & 0 deletions errutil/error.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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:]...)...)
}
27 changes: 27 additions & 0 deletions errutil/error_example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
223 changes: 221 additions & 2 deletions errutil/error_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down

0 comments on commit 60e43ab

Please sign in to comment.