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

Implement custom error handling to Storer interface #590

Closed
wants to merge 6 commits into from
Closed
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
32 changes: 32 additions & 0 deletions 07_errors/patrickmarabeas/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package main

import "fmt"

type ErrorCode int

type Error struct {
Code ErrorCode
Message string
}

const (
NegativeValue ErrorCode = iota + 1001
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is not clear why you have decided to start at 1001 for the error codes, and also set Unknown to 1999. What if in future you need >1000 error codes? Since this is an enum, the actual values for the enum are not relevant, unless they map to some external system. In that case you should document that mapping. But if there is one, don't suggest there is one but you're not telling by using a different set of meaningless numbers to the default.

I'd suggest starting with "Unknown = iota" and going up from there. That does not give you that artificial limit of the number of error codes when you do eventually get to more than 1000 error codes (which will come up in lab 22).

Perhaps you have chosen to use 1000+ so you get a consistent formatting width when converting the error to a string, but I'd suggest you do formatting at the place where formatting is specified (i.e. [%04d])

IDNotFound
Unknown = 1999
)

func (e *Error) Error() string {
return fmt.Sprintf("[%d] %s", e.Code, e.Message)
}

// NewError creates a new error with the given enum
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: Godoc comments should end with a full-stop, as they appear as sentences in the documentation (https://blog.golang.org/godoc-documenting-go-code).

another nitpick: I'd say "given error code" instead of "given enum" - seems a little clearer to me.

func NewError(code ErrorCode) error {
switch code {
case NegativeValue:
return &Error{code, "Puppy value must be greater than 0"}
case IDNotFound:
return &Error{code, "Nonexistent Puppy ID"}
default:
return &Error{Unknown, "Unknown error"}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This throws away the error code passed in, which may be useful when trying to debug where a strange error is coming from. Perhaps set the string as fmt.Sprintf("Unknown error (%d)", code) if code != Unknown? Just an idea.

}
}
22 changes: 22 additions & 0 deletions 07_errors/patrickmarabeas/errors_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package main

import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestUnknownError(t *testing.T) {
a := assert.New(t)

error := NewError(123)
a.Equal(error, NewError(Unknown))
}

func TestError(t *testing.T) {
a := assert.New(t)

error := Error{1, "message"}
formattedError := error.Error()
a.Equal(formattedError, "[1] message")
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see that you have 100% test coverage with this commit. There are a couple of branches in NewError() that are not tested. They probably get tested in a later commit by using it, but since you have structured your PR as a series of small cohesive commits (good), you should ensure each commit passes all the tests and has complete coverage. There is some git rebase --exec magic you can use to run all your tests and coverage checks on every one of your commits automagically, but that usually requires a makefile or script to run all the checks you want to.

I will share the magic in a subsequent comment where I have noticed your tests failing...

13 changes: 13 additions & 0 deletions 07_errors/patrickmarabeas/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package main

import (
"fmt"
"io"
"os"
)

var out io.Writer = os.Stdout

func main() {
fmt.Fprint(out, "hello")
}
21 changes: 21 additions & 0 deletions 07_errors/patrickmarabeas/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package main

import (
"bytes"
"testing"
)

func TestMainOutput(t *testing.T) {
var buf bytes.Buffer
out = &buf

main()

expected := "hello"

got := buf.String()

if expected != got {
t.Errorf("\nExpected: %s\nGot: %s", expected, got)
}
}
58 changes: 58 additions & 0 deletions 07_errors/patrickmarabeas/mapStore.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package main

// NewMapStore returns a pointer to a new instance of the MapStore struct which implements the Storer interface.
func NewMapStore() Storer {
return &MapStore{
uuid: 0,
store: map[int]Puppy{},
}
}

// Create increments the uuid and adds the provided Puppy struct to the store with this identifier.
func (store *MapStore) Create(puppy Puppy) (int, error) {
if puppy.Value < 0 {
return -1, NewError(NegativeValue)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be return 0, NewError(NegativeValue)

When an error is returned, the value of the other return parameters should be the zero value for their type. Otherwise people may start checking the wrong value for errors, or try to use those values thinking there might be some significance to the non-zero-value.

}

puppy.ID = store.uuid
store.store[puppy.ID] = puppy
store.uuid++

return puppy.ID, nil
}

// Read returns the puppy matching the provided uuid.
// An empty Puppy struct is returned if the identifier does not exist.
func (store *MapStore) Read(id int) (Puppy, error) {
if _, ok := store.store[id]; ok {
return store.store[id], nil
}

return Puppy{}, NewError(IDNotFound)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two comments about this function (and I know my comments really apply to lab 6, not this one but here we are now):

  1. Usually you want to align the happy path to the left edge, so the indented case should be returning the error,
  2. Since you have the non-error path inside the if, rather than indexing the map again you should save the value rather than using the blank identifier:
if p, ok := store.store[id]; ok {
    return p, nil
}

unless you use a var to declare p outside the scope of the if. Combined, it would look like:

func (store *MapStore) Read(id int) (Puppy, error) {
    var p Puppy
    if p, ok := store.store[id]; !ok {
        return Puppy{}, NewError(IDNotFound)
    }
    return p, nil
}

}

// Update modifies the puppy matching the provided uuid in the store with the provided Puppy struct.
// It returns a bool whether a matching identifier was modified or not.
func (store *MapStore) Update(id int, puppy Puppy) (bool, error) {
if _, ok := store.store[id]; !ok {
return false, NewError(IDNotFound)
}
if puppy.Value < 0 {
return false, NewError(NegativeValue)
}

puppy.ID = id
store.store[id] = puppy
return true, nil
}

// Destroy removes the puppy matching the provided uuid from the store.
// It returns a bool whether a matching identifier was deleted or not.
func (store *MapStore) Destroy(id int) (bool, error) {
if _, ok := store.store[id]; !ok {
return false, NewError(IDNotFound)
}

delete(store.store, id)
return true, nil
}
116 changes: 116 additions & 0 deletions 07_errors/patrickmarabeas/store_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package main

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
)

type StoreSuite struct {
suite.Suite
store Storer
}

func (suite *StoreSuite) TestCreate() {
a := assert.New(suite.T())

id, error := suite.store.Create(Puppy{Breed: "Wolf", Color: "Grey", Value: 450})
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be called err not error as error is the built-in error type.

a.Equal(id, 0)
a.Equal(error, nil)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use a.NoError(err) rather than comparing to nil

}

func (suite *StoreSuite) TestCreateSecond() {
a := assert.New(suite.T())

id, error := suite.store.Create(Puppy{Breed: "Boxer", Color: "Brown", Value: 300})
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

s/error/err/

a.Equal(id, 1)
a.Equal(error, nil)
}

func (suite *StoreSuite) TestCreateNegativeNumber() {
a := assert.New(suite.T())

id, error := suite.store.Create(Puppy{Breed: "Wolf", Color: "Grey", Value: -100})
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

s/error/err/

a.Equal(id, -1)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

id should be irrelevant for the error case - no point testing it.

a.Equal(error, NewError(NegativeValue))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a.Error(...)

}

func (suite *StoreSuite) TestRead() {
a := assert.New(suite.T())

data, error := suite.store.Read(0)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

s/error/err/

Going to stop saying this now. Please fix throughout

a.Equal(data, Puppy{ID: 0, Breed: "Wolf", Color: "Grey", Value: 450})
a.Equal(error, nil)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a.NoError(err)

Going to stop saying this now. Please fix throughout.

}

func (suite *StoreSuite) TestReadNonExistent() {
a := assert.New(suite.T())

success, error := suite.store.Read(100)
a.Equal(success, Puppy{})
a.Equal(error, NewError(IDNotFound))
}

func (suite *StoreSuite) TestUpdate() {
a := assert.New(suite.T())

success, error := suite.store.Update(0, Puppy{Breed: "Doberman", Color: "Black", Value: 500})
a.Equal(success, true)
a.Equal(error, nil)

data, error := suite.store.Read(0)
a.Equal(data, Puppy{ID: 0, Breed: "Doberman", Color: "Black", Value: 500})
a.Equal(error, nil)
}

func (suite *StoreSuite) TestUpdateNonExistent() {
a := assert.New(suite.T())

success, error := suite.store.Update(100, Puppy{Breed: "Doberman", Color: "Black", Value: 500})
a.Equal(success, false)
a.Equal(error, NewError(IDNotFound))
}

func (suite *StoreSuite) TestUpdateNegativeNumber() {
a := assert.New(suite.T())

success, error := suite.store.Update(0, Puppy{Breed: "Doberman", Color: "Black", Value: -500})
a.Equal(success, false)
a.Equal(error, NewError(NegativeValue))
}

func (suite *StoreSuite) TestDestroy() {
a := assert.New(suite.T())

success, error := suite.store.Destroy(1)
a.Equal(success, true)
a.Equal(error, nil)

data, error := suite.store.Read(1)
a.Equal(data, Puppy{})
a.Equal(error, NewError(IDNotFound))
}

func (suite *StoreSuite) TestDestroyNonExistent() {
a := assert.New(suite.T())

success, error := suite.store.Destroy(100)
a.Equal(success, false)
a.Equal(error, NewError(IDNotFound))
}

func (suite *StoreSuite) TestIdIncrementOnDelete() {
a := assert.New(suite.T())
id, _ := suite.store.Create(Puppy{Breed: "Greyhound", Color: "Light Brown", Value: 700})
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than ignoring the error here, perhaps use require?

import "github.com/stretchr/testify/require"
...
require.NoError(suite.T(), err)

require will stop the test there, and I find it useful to use it to differentiate between test setup that is failing and the results of the tests themselves (for which I use assert).

success, _ := suite.store.Destroy(id)
a.Equal(success, true)

newID, _ := suite.store.Create(Puppy{Breed: "Greyhound", Color: "Light Brown", Value: 700})
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar. You do not expect this Create() call to fail, so require that it does not.

a.Equal(newID, 3)
}

func TestStore(t *testing.T) {
suite.Run(t, &StoreSuite{store: NewMapStore()})
suite.Run(t, &StoreSuite{store: NewSyncStore()})
}
65 changes: 65 additions & 0 deletions 07_errors/patrickmarabeas/syncStore.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package main

// NewSyncStore returns a pointer to a new instance of the SyncStore struct which implements the Storer interface.
func NewSyncStore() Storer {
return &SyncStore{
uuid: 0,
}
}

// Create increments the uuid and adds the provided Puppy struct to the store with this identifier.
func (store *SyncStore) Create(puppy Puppy) (int, error) {
if puppy.Value < 0 {
return -1, NewError(NegativeValue)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

similar to mapstore - use a zero value for unspecified return values.

}

store.Lock()
puppy.ID = store.uuid
store.Store(puppy.ID, puppy)
store.uuid++
store.Unlock()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The usual Go pattern is to put this right after the Lock() with defer:

store.Lock()
defer store.Unlock()

This way if you later add an early return in the middle for error handling, you do not forget to unlock.


return puppy.ID, nil
}

// Read returns the puppy matching the provided uuid.
// An empty Puppy struct is returned if the identifier does not exist.
func (store *SyncStore) Read(id int) (Puppy, error) {
store.RLock()
if value, ok := store.Load(id); ok {
return value.(Puppy), nil
}
store.RUnlock()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bug, because you are not unlocking the store inside the if statement. Instead use the defer pattern above and not worry about it any more.


return Puppy{}, NewError(IDNotFound)
}

// Update modifies the puppy matching the provided uuid in the store with the provided Puppy struct.
// It returns a bool whether a matching identifier was modified or not.
func (store *SyncStore) Update(id int, puppy Puppy) (bool, error) {
if _, ok := store.Load(id); !ok {
return false, NewError(IDNotFound)
}
if puppy.Value < 0 {
return false, NewError(NegativeValue)
}

puppy.ID = id
store.Store(id, puppy)

return true, nil
}

// Destroy removes the puppy matching the provided uuid from the store.
// It returns a bool whether a matching identifier was deleted or not.
func (store *SyncStore) Destroy(id int) (bool, error) {
if _, ok := store.Load(id); !ok {
return false, NewError(IDNotFound)
}

store.Lock()
store.Delete(id)
store.Unlock()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ditto. use the defer pattern, even for a simple one line block like this one.


return true, nil
}
28 changes: 28 additions & 0 deletions 07_errors/patrickmarabeas/types.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package main

import "sync"

type Puppy struct {
ID int
Breed string
Color string
Value int // cents
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two ways you could convey this information without a comment:

  1. Change the field name to ValueCents
  2. Add a new type: type Cents int

Just a thought.

}

type Storer interface {
Create(puppy Puppy) (int, error)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This commit on its own breaks the code. If you run go test ./... on this commit, the tests will fail - the build will fail too. This is bad as it breaks git bisect - you want every commit to build and pass tests.

The most minimal version of this change would be to change the implementation to always return nil for the error when implementing it, and a subsequent commit can start returning non-nil errors where appropriate.

Read(ID int) (Puppy, error)
Update(ID int, puppy Puppy) (bool, error)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the return value of Update() was an error of sorts before, does it makes sense to keep the bool here? Just return nil or an error perhaps.

Destroy(ID int) (bool, error)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here

}

type MapStore struct {
uuid int
store map[int]Puppy
}

type SyncStore struct {
uuid int
sync.Map
sync.RWMutex
}