Skip to content

Commit

Permalink
Merge pull request #10 from ccremer/context
Browse files Browse the repository at this point in the history
Add Pipeline Context
  • Loading branch information
ccremer authored Aug 27, 2021
2 parents 80ee000 + 44d528b commit ffcf398
Show file tree
Hide file tree
Showing 17 changed files with 551 additions and 75 deletions.
65 changes: 59 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,10 @@

![Go version](https://img.shields.io/github/go-mod/go-version/ccremer/go-command-pipeline)
[![Version](https://img.shields.io/github/v/release/ccremer/go-command-pipeline)][releases]
[![GitHub downloads](https://img.shields.io/github/downloads/ccremer/go-command-pipeline/total)][releases]
[![Go Report Card](https://goreportcard.com/badge/github.com/ccremer/go-command-pipeline)][goreport]
[![Codecov](https://img.shields.io/codecov/c/github/ccremer/go-command-pipeline?token=XGOC4XUMJ5)][codecov]

Small Go utility that executes high-level actions in a pipeline fashion.
Especially useful when combined with the Facade design pattern.
Small Go utility that executes business actions in a pipeline.

## Usage

Expand All @@ -20,16 +18,71 @@ import (
func main() {
p := pipeline.NewPipeline()
p.WithSteps(
predicate.ToStep("clone repository", CloneGitRepository(), predicate.Not(DirExists("my-repo"))),
pipeline.NewStep("checkout branch", CheckoutBranch()),
pipeline.NewStep("pull", Pull()),
pipeline.NewStep("define random number", defineNumber),
pipeline.NewStepFromFunc("print number", printNumber),
)
result := p.Run()
if !result.IsSuccessful() {
log.Fatal(result.Err)
}
}

func defineNumber(ctx pipeline.Context) pipeline.Result {
ctx.SetValue("number", rand.Int())
return pipeline.Result{}
}

// Let's assume this is a business function that can fail.
// You can enable "automatic" fail-on-first-error pipelines by having more small functions that return errors.
func printNumber(ctx pipeline.Context) error {
_, err := fmt.Println(ctx.IntValue("number", 0))
return err
}
```

## Who is it for

This utility is interesting for you if you have many business functions that are executed sequentially, each with their own error handling.
Do you grow tired of the tedious error handling in Go when all you do is passing the error "up" in the stack in over 90% of the cases, only to log it at the root?
This utility helps you focus on the business logic by dividing each failure-prone action into small steps since pipeline aborts on first error.

Consider the following prose example:
```go
func Persist(data Data) error {
err := database.prepareTransaction()
if err != nil {
return err
}
err = database.executeQuery("SOME QUERY", data)
if err != nil {
return err
}
err = database.commit()
return err
}
```
We have tons of `if err != nil` that bloats the function with more error handling than actual interesting business logic.

It could be simplified to something like this:
```go
func Persist(data Data) error {
p := pipeline.NewPipeline().WithSteps(
pipeline.NewStep("prepareTransaction", prepareTransaction()),
pipeline.NewStep("executeQuery", executeQuery(data)),
pipeline.NewStep("commitTransaction", commit()),
)
return p.Run().Err
}

func executeQuery(data Data) pipeline.ActionFunc {
return func(_ pipeline.Context) pipeline.Result {
err := database.executeQuery("SOME QUERY", data)
return pipeline.Result{Err: err}
}
}
...
```
While it seems to add more lines in order to set up a pipeline, it makes it very easily understandable what `Persist()` does without all the error handling.

[releases]: https://github.com/ccremer/go-command-pipeline/releases
[codecov]: https://app.codecov.io/gh/ccremer/go-command-pipeline
Expand Down
5 changes: 4 additions & 1 deletion codecov.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
comment: false
coverage:
status:
patch:
default:
threshold: 50%
project:
default:
threshold: 5%
threshold: 10%
100 changes: 100 additions & 0 deletions context.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package pipeline

// Context contains data relevant for the pipeline execution.
// It's primary purpose is to store and retrieve data within an ActionFunc.
type Context interface {
// Value returns the raw value identified by key.
// Returns nil if the key doesn't exist.
Value(key interface{}) interface{}
// ValueOrDefault returns the value identified by key if it exists.
// If not, then the given default value is returned.
ValueOrDefault(key interface{}, defaultValue interface{}) interface{}
// StringValue is a sugared accessor like ValueOrDefault, but converts the value to string.
// If the key cannot be found or if the value is not of type string, then the defaultValue is returned.
StringValue(key interface{}, defaultValue string) string
// BoolValue is a sugared accessor like ValueOrDefault, but converts the value to bool.
// If the key cannot be found or if the value is not of type bool, then the defaultValue is returned.
BoolValue(key interface{}, defaultValue bool) bool
// IntValue is a sugared accessor like ValueOrDefault, but converts the value to int.
// If the key cannot be found or if the value is not of type int, then the defaultValue is returned.
IntValue(key interface{}, defaultValue int) int
// SetValue sets the value at the given key.
SetValue(key interface{}, value interface{})
}

// DefaultContext implements Context using a Map internally.
type DefaultContext struct {
values map[interface{}]interface{}
}

// Value implements Context.Value.
func (ctx *DefaultContext) Value(key interface{}) interface{} {
if ctx.values == nil {
return nil
}
return ctx.values[key]
}

// ValueOrDefault implements Context.ValueOrDefault.
func (ctx *DefaultContext) ValueOrDefault(key interface{}, defaultValue interface{}) interface{} {
if ctx.values == nil {
return defaultValue
}
if raw, exists := ctx.values[key]; exists {
return raw
}
return defaultValue
}

// StringValue implements Context.StringValue.
func (ctx *DefaultContext) StringValue(key interface{}, defaultValue string) string {
if ctx.values == nil {
return defaultValue
}
raw, exists := ctx.values[key]
if !exists {
return defaultValue
}
if strValue, isString := raw.(string); isString {
return strValue
}
return defaultValue
}

// BoolValue implements Context.BoolValue.
func (ctx *DefaultContext) BoolValue(key interface{}, defaultValue bool) bool {
if ctx.values == nil {
return defaultValue
}
raw, exists := ctx.values[key]
if !exists {
return defaultValue
}
if boolValue, isBool := raw.(bool); isBool {
return boolValue
}
return defaultValue
}

// IntValue implements Context.IntValue.
func (ctx *DefaultContext) IntValue(key interface{}, defaultValue int) int {
if ctx.values == nil {
return defaultValue
}
raw, exists := ctx.values[key]
if !exists {
return defaultValue
}
if intValue, isInt := raw.(int); isInt {
return intValue
}
return defaultValue
}

// SetValue implements Context.SetValue.
func (ctx *DefaultContext) SetValue(key interface{}, value interface{}) {
if ctx.values == nil {
ctx.values = map[interface{}]interface{}{}
}
ctx.values[key] = value
}
164 changes: 164 additions & 0 deletions context_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
package pipeline

import (
"fmt"
"testing"

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

const stringKey = "stringKey"
const boolKey = "boolKey"
const intKey = "intKey"
const valueKey = "value"

func TestDefaultContext_Implements_Context(t *testing.T) {
assert.Implements(t, (*Context)(nil), new(DefaultContext))
}

type valueTestCase struct {
givenValues map[interface{}]interface{}

defaultBool bool
defaultString string
defaultInt int

expectedBool bool
expectedString string
expectedInt int
}

var valueTests = map[string]valueTestCase{
"GivenNilValues_ThenExpectDefaults": {
givenValues: nil,
},
"GivenNonExistingKey_ThenExpectDefaults": {
givenValues: map[interface{}]interface{}{},
defaultBool: true,
expectedBool: true,
defaultString: "default",
expectedString: "default",
defaultInt: 10,
expectedInt: 10,
},
"GivenExistingKey_WhenInvalidType_ThenExpectDefaults": {
givenValues: map[interface{}]interface{}{
boolKey: "invalid",
stringKey: 0,
intKey: "invalid",
},
defaultBool: true,
expectedBool: true,
defaultString: "default",
expectedString: "default",
defaultInt: 10,
expectedInt: 10,
},
"GivenExistingKey_WhenValidType_ThenExpectValues": {
givenValues: map[interface{}]interface{}{
boolKey: true,
stringKey: "string",
intKey: 10,
},
expectedBool: true,
expectedString: "string",
expectedInt: 10,
},
}

func TestDefaultContext_BoolValue(t *testing.T) {
for name, tt := range valueTests {
t.Run(name, func(t *testing.T) {
ctx := DefaultContext{values: tt.givenValues}
result := ctx.BoolValue(boolKey, tt.defaultBool)
assert.Equal(t, tt.expectedBool, result)
})
}
}

func TestDefaultContext_StringValue(t *testing.T) {
for name, tt := range valueTests {
t.Run(name, func(t *testing.T) {
ctx := DefaultContext{values: tt.givenValues}
result := ctx.StringValue(stringKey, tt.defaultString)
assert.Equal(t, tt.expectedString, result)
})
}
}

func TestDefaultContext_IntValue(t *testing.T) {
for name, tt := range valueTests {
t.Run(name, func(t *testing.T) {
ctx := DefaultContext{values: tt.givenValues}
result := ctx.IntValue(intKey, tt.defaultInt)
assert.Equal(t, tt.expectedInt, result)
})
}
}

func TestDefaultContext_SetValue(t *testing.T) {
ctx := DefaultContext{values: map[interface{}]interface{}{}}
ctx.SetValue(stringKey, "string")
assert.Equal(t, "string", ctx.values[stringKey])
}

func TestDefaultContext_Value(t *testing.T) {
t.Run("GivenNilValues_ThenExpectNil", func(t *testing.T) {
ctx := DefaultContext{values: nil}
result := ctx.Value(valueKey)
assert.Nil(t, result)
})
t.Run("GivenNonExistingKey_ThenExpectNil", func(t *testing.T) {
ctx := DefaultContext{values: map[interface{}]interface{}{}}
result := ctx.Value(valueKey)
assert.Nil(t, result)
})
t.Run("GivenExistingKey_WhenKeyContainsNil_ThenExpectNil", func(t *testing.T) {
ctx := DefaultContext{values: map[interface{}]interface{}{
valueKey: nil,
}}
result := ctx.Value(valueKey)
assert.Nil(t, result)
})
}

func TestDefaultContext_ValueOrDefault(t *testing.T) {
t.Run("GivenNilValues_ThenExpectDefault", func(t *testing.T) {
ctx := DefaultContext{values: nil}
result := ctx.ValueOrDefault(valueKey, valueKey)
assert.Equal(t, result, valueKey)
})
t.Run("GivenNonExistingKey_ThenExpectDefault", func(t *testing.T) {
ctx := DefaultContext{values: map[interface{}]interface{}{}}
result := ctx.ValueOrDefault(valueKey, valueKey)
assert.Equal(t, result, valueKey)
})
t.Run("GivenExistingKey_ThenExpectValue", func(t *testing.T) {
ctx := DefaultContext{values: map[interface{}]interface{}{
valueKey: valueKey,
}}
result := ctx.ValueOrDefault(valueKey, "default")
assert.Equal(t, result, valueKey)
})
}

func ExampleDefaultContext_BoolValue() {
ctx := DefaultContext{}
ctx.SetValue("key", true)
fmt.Println(ctx.BoolValue("key", false))
// Output: true
}

func ExampleDefaultContext_StringValue() {
ctx := DefaultContext{}
ctx.SetValue("key", "string")
fmt.Println(ctx.StringValue("key", "default"))
// Output: string
}

func ExampleDefaultContext_IntValue() {
ctx := DefaultContext{}
ctx.SetValue("key", 1)
fmt.Println(ctx.IntValue("key", 0))
// Output: 1
}
Loading

0 comments on commit ffcf398

Please sign in to comment.