Skip to content

Commit

Permalink
Merge pull request #14 from Kaligo/feature/result-monad-guard-clauses
Browse files Browse the repository at this point in the history
Add guard clauses through new ResultMonad::GuardClause mixin
  • Loading branch information
Drenmi authored Jun 29, 2021
2 parents 1fcc5a2 + b2fae00 commit ea79c14
Show file tree
Hide file tree
Showing 8 changed files with 260 additions and 2 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## 0.7.0

### New features

- Add guard clauses through new `ResultMonad::GuardClause` mixin.

## 0.6.1

### New features
Expand Down
2 changes: 1 addition & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
stimpack (0.6.0)
stimpack (0.7.0)
activesupport (~> 6.1)

GEM
Expand Down
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ and behaviour.
- [FunctionalObject](#functionalobject)
- [OptionsDeclaration](#optionsdeclaration)
- [ResultMonad](#resultmonad)
- [Callbacks](#callbacks)
- [Guard clauses](#guard-clauses)

## EventSource

Expand Down Expand Up @@ -293,3 +295,52 @@ end

*Note: The block is evaluated in the context of the instance, so you can call
any instance methods from inside the block.*

### Guard clauses

The `ResultMonad::GuardClause` mixin (included by default) allows for stepwise
calling of inner, or nested, `ResultMonad` instances with automatic error
propagation. This currently works for the `#call` method only.

**Example:**

```ruby
class Foo
include Stimpack::ResultMonad

before_error do
log_tracking_data
end

def call
guard :bar_guard
guard { baz_guard }
end

private

def log_tracking_data
# ...
end

def bar_guard
Bar.() # Another ResultMonad.
end

def baz_guard
if qux?
error(errors: ["Qux failed."])
else
success
end
end
end
```

In the example above, if either of the methods declared as guards return a
failed `Result`, the `#call` method will halt execution, invoke the error
callback, and return the result from the inner monad. On the other hand, as
long as the guards return a success `Result`, the execution continues as
expected.

*Note: Any error callbacks declared on the inner monad will also be invoked.*
2 changes: 2 additions & 0 deletions lib/stimpack/result_monad.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# frozen_string_literal: true

require_relative "./result_monad/result"
require_relative "./result_monad/guard_clause"

module Stimpack
# This mixin augments its consumer class with methods to return structured
Expand Down Expand Up @@ -120,6 +121,7 @@ def setup_callback(name, &block)

def self.included(klass)
klass.extend(ClassMethods)
klass.include(GuardClause)
end

private
Expand Down
69 changes: 69 additions & 0 deletions lib/stimpack/result_monad/guard_clause.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# frozen_string_literal: true

module Stimpack
module ResultMonad
# This module adds a `#guard` method, which can be used inside a `#call`
# to declare a step which, if it fails, breaks the flow of the method and
# propagates the error result.
#
# Example:
#
# def call
# guard :price_check
#
# ...
# end
#
# In the above example, if the price check fails, the wrapping service
# will halt and return an error result. This replaces use of `return`, and
# has the benefit of invoking all related callbacks on *both* services if
# the guard fails.
#
module GuardClause
# This module prepends a wrapper `#call` method which "catches" errors
# returned from the guarded service, and propagates the error result.
#
module GuardCatcher
def call
super
rescue GuardFailed => e
run_callback(:error)

e.result
end
end

# This error is used to break out of the current execution flow when a
# guard fails. It carries the error result with it, and passes it to the
# caller which can then work with it.
#
class GuardFailed < StandardError
# rubocop:disable Lint/MissingSuper
def initialize(result)
@result = result
end
# rubocop:enable Lint/MissingSuper

attr_reader :result
end

# The guard declaration takes either a label, a block, or both (in which
# case the block takes precedence.) A label is interpreted as an instance
# method of the service.
#
def guard(label = nil, &block)
raise ArgumentError, "Guard needs either a label or a block." if label.nil? && !block_given?

result = block_given? ? instance_eval(&block) : send(label)

raise GuardFailed, result if result.failed?

result
end

def self.included(klass)
klass.prepend(GuardCatcher)
end
end
end
end
2 changes: 1 addition & 1 deletion lib/stimpack/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module Stimpack
VERSION = "0.6.1"
VERSION = "0.7.0"
end
4 changes: 4 additions & 0 deletions spec/spec_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
# This option will default to `true` in RSpec 4.
#
expectations.include_chain_clauses_in_custom_matcher_descriptions = true

# We intentionally expect no error raised with a specific error class.
#
expectations.on_potential_false_positives = :nothing
end

config.mock_with :rspec do |mocks|
Expand Down
126 changes: 126 additions & 0 deletions spec/stimpack/result_monad/guard_clause_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# frozen_string_literal: true

require "stimpack/result_monad"

RSpec.describe Stimpack::ResultMonad::GuardClause do
subject(:service) { klass }

let(:instance) { service.new }

let(:klass) do
Class.new do
include Stimpack::ResultMonad

result :foo

def success_result(**options)
success(**options)
end

def error_result(errors:)
error(errors: errors)
end

def self.to_s
"Foo"
end

def call
guard :foo
guard { bar }

success(foo: "bar")
end

private

def foo
# Stubbed in test cases.
end

def bar
# Stubbed in test cases.
end
end
end

let(:inner_service_error) do
double(
Stimpack::ResultMonad::Result,
failed?: true,
errors: ["Inner error"]
)
end

let(:inner_service_success) do
double(
Stimpack::ResultMonad::Result,
failed?: false,
errors: []
)
end

describe "#guard" do
context "when using a label" do
it { expect { instance.guard(:foo) }.not_to raise_error(ArgumentError) }
end

context "when using a block" do
it { expect { instance.guard { :foo } }.not_to raise_error(ArgumentError) }
end

context "when using both a label and a block" do
it { expect { instance.guard(:foo) { :foo } }.not_to raise_error(ArgumentError) }
end

context "when using no arguments" do
it { expect { instance.guard }.to raise_error(ArgumentError) }
end
end

describe ".call" do
context "when all guards pass" do
before do
allow(instance).to receive(:foo).and_return(inner_service_success)
allow(instance).to receive(:bar).and_return(inner_service_success)
end

it { expect(instance.()).to be_successful }
end

context "when a guard fails" do
context "when guard is invoked using a label" do
before do
allow(instance).to receive(:foo).and_return(inner_service_error)
allow(instance).to receive(:bar).and_return(inner_service_success)
end

it { expect(instance.()).to be_failed }
it { expect(instance.().errors).to eq(["Inner error"]) }
end

context "when guard is invoked using a block" do
before do
allow(instance).to receive(:foo).and_return(inner_service_success)
allow(instance).to receive(:bar).and_return(inner_service_error)
end

it { expect(instance.()).to be_failed }
it { expect(instance.().errors).to eq(["Inner error"]) }
end
end
end

describe ".before_error" do
before do
allow(instance).to receive(:inspect)
allow(instance).to receive(:foo).and_return(inner_service_error)

service.before_error { inspect }

instance.()
end

it { expect(instance).to have_received(:inspect).once }
end
end

0 comments on commit ea79c14

Please sign in to comment.