Skip to content

Commit ea79c14

Browse files
authored
Merge pull request #14 from Kaligo/feature/result-monad-guard-clauses
Add guard clauses through new ResultMonad::GuardClause mixin
2 parents 1fcc5a2 + b2fae00 commit ea79c14

File tree

8 files changed

+260
-2
lines changed

8 files changed

+260
-2
lines changed

CHANGELOG.md

+6
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## 0.7.0
4+
5+
### New features
6+
7+
- Add guard clauses through new `ResultMonad::GuardClause` mixin.
8+
39
## 0.6.1
410

511
### New features

Gemfile.lock

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PATH
22
remote: .
33
specs:
4-
stimpack (0.6.0)
4+
stimpack (0.7.0)
55
activesupport (~> 6.1)
66

77
GEM

README.md

+51
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ and behaviour.
1212
- [FunctionalObject](#functionalobject)
1313
- [OptionsDeclaration](#optionsdeclaration)
1414
- [ResultMonad](#resultmonad)
15+
- [Callbacks](#callbacks)
16+
- [Guard clauses](#guard-clauses)
1517

1618
## EventSource
1719

@@ -293,3 +295,52 @@ end
293295

294296
*Note: The block is evaluated in the context of the instance, so you can call
295297
any instance methods from inside the block.*
298+
299+
### Guard clauses
300+
301+
The `ResultMonad::GuardClause` mixin (included by default) allows for stepwise
302+
calling of inner, or nested, `ResultMonad` instances with automatic error
303+
propagation. This currently works for the `#call` method only.
304+
305+
**Example:**
306+
307+
```ruby
308+
class Foo
309+
include Stimpack::ResultMonad
310+
311+
before_error do
312+
log_tracking_data
313+
end
314+
315+
def call
316+
guard :bar_guard
317+
guard { baz_guard }
318+
end
319+
320+
private
321+
322+
def log_tracking_data
323+
# ...
324+
end
325+
326+
def bar_guard
327+
Bar.() # Another ResultMonad.
328+
end
329+
330+
def baz_guard
331+
if qux?
332+
error(errors: ["Qux failed."])
333+
else
334+
success
335+
end
336+
end
337+
end
338+
```
339+
340+
In the example above, if either of the methods declared as guards return a
341+
failed `Result`, the `#call` method will halt execution, invoke the error
342+
callback, and return the result from the inner monad. On the other hand, as
343+
long as the guards return a success `Result`, the execution continues as
344+
expected.
345+
346+
*Note: Any error callbacks declared on the inner monad will also be invoked.*

lib/stimpack/result_monad.rb

+2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# frozen_string_literal: true
22

33
require_relative "./result_monad/result"
4+
require_relative "./result_monad/guard_clause"
45

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

121122
def self.included(klass)
122123
klass.extend(ClassMethods)
124+
klass.include(GuardClause)
123125
end
124126

125127
private
+69
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# frozen_string_literal: true
2+
3+
module Stimpack
4+
module ResultMonad
5+
# This module adds a `#guard` method, which can be used inside a `#call`
6+
# to declare a step which, if it fails, breaks the flow of the method and
7+
# propagates the error result.
8+
#
9+
# Example:
10+
#
11+
# def call
12+
# guard :price_check
13+
#
14+
# ...
15+
# end
16+
#
17+
# In the above example, if the price check fails, the wrapping service
18+
# will halt and return an error result. This replaces use of `return`, and
19+
# has the benefit of invoking all related callbacks on *both* services if
20+
# the guard fails.
21+
#
22+
module GuardClause
23+
# This module prepends a wrapper `#call` method which "catches" errors
24+
# returned from the guarded service, and propagates the error result.
25+
#
26+
module GuardCatcher
27+
def call
28+
super
29+
rescue GuardFailed => e
30+
run_callback(:error)
31+
32+
e.result
33+
end
34+
end
35+
36+
# This error is used to break out of the current execution flow when a
37+
# guard fails. It carries the error result with it, and passes it to the
38+
# caller which can then work with it.
39+
#
40+
class GuardFailed < StandardError
41+
# rubocop:disable Lint/MissingSuper
42+
def initialize(result)
43+
@result = result
44+
end
45+
# rubocop:enable Lint/MissingSuper
46+
47+
attr_reader :result
48+
end
49+
50+
# The guard declaration takes either a label, a block, or both (in which
51+
# case the block takes precedence.) A label is interpreted as an instance
52+
# method of the service.
53+
#
54+
def guard(label = nil, &block)
55+
raise ArgumentError, "Guard needs either a label or a block." if label.nil? && !block_given?
56+
57+
result = block_given? ? instance_eval(&block) : send(label)
58+
59+
raise GuardFailed, result if result.failed?
60+
61+
result
62+
end
63+
64+
def self.included(klass)
65+
klass.prepend(GuardCatcher)
66+
end
67+
end
68+
end
69+
end

lib/stimpack/version.rb

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# frozen_string_literal: true
22

33
module Stimpack
4-
VERSION = "0.6.1"
4+
VERSION = "0.7.0"
55
end

spec/spec_helper.rb

+4
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55
# This option will default to `true` in RSpec 4.
66
#
77
expectations.include_chain_clauses_in_custom_matcher_descriptions = true
8+
9+
# We intentionally expect no error raised with a specific error class.
10+
#
11+
expectations.on_potential_false_positives = :nothing
812
end
913

1014
config.mock_with :rspec do |mocks|
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
# frozen_string_literal: true
2+
3+
require "stimpack/result_monad"
4+
5+
RSpec.describe Stimpack::ResultMonad::GuardClause do
6+
subject(:service) { klass }
7+
8+
let(:instance) { service.new }
9+
10+
let(:klass) do
11+
Class.new do
12+
include Stimpack::ResultMonad
13+
14+
result :foo
15+
16+
def success_result(**options)
17+
success(**options)
18+
end
19+
20+
def error_result(errors:)
21+
error(errors: errors)
22+
end
23+
24+
def self.to_s
25+
"Foo"
26+
end
27+
28+
def call
29+
guard :foo
30+
guard { bar }
31+
32+
success(foo: "bar")
33+
end
34+
35+
private
36+
37+
def foo
38+
# Stubbed in test cases.
39+
end
40+
41+
def bar
42+
# Stubbed in test cases.
43+
end
44+
end
45+
end
46+
47+
let(:inner_service_error) do
48+
double(
49+
Stimpack::ResultMonad::Result,
50+
failed?: true,
51+
errors: ["Inner error"]
52+
)
53+
end
54+
55+
let(:inner_service_success) do
56+
double(
57+
Stimpack::ResultMonad::Result,
58+
failed?: false,
59+
errors: []
60+
)
61+
end
62+
63+
describe "#guard" do
64+
context "when using a label" do
65+
it { expect { instance.guard(:foo) }.not_to raise_error(ArgumentError) }
66+
end
67+
68+
context "when using a block" do
69+
it { expect { instance.guard { :foo } }.not_to raise_error(ArgumentError) }
70+
end
71+
72+
context "when using both a label and a block" do
73+
it { expect { instance.guard(:foo) { :foo } }.not_to raise_error(ArgumentError) }
74+
end
75+
76+
context "when using no arguments" do
77+
it { expect { instance.guard }.to raise_error(ArgumentError) }
78+
end
79+
end
80+
81+
describe ".call" do
82+
context "when all guards pass" do
83+
before do
84+
allow(instance).to receive(:foo).and_return(inner_service_success)
85+
allow(instance).to receive(:bar).and_return(inner_service_success)
86+
end
87+
88+
it { expect(instance.()).to be_successful }
89+
end
90+
91+
context "when a guard fails" do
92+
context "when guard is invoked using a label" do
93+
before do
94+
allow(instance).to receive(:foo).and_return(inner_service_error)
95+
allow(instance).to receive(:bar).and_return(inner_service_success)
96+
end
97+
98+
it { expect(instance.()).to be_failed }
99+
it { expect(instance.().errors).to eq(["Inner error"]) }
100+
end
101+
102+
context "when guard is invoked using a block" do
103+
before do
104+
allow(instance).to receive(:foo).and_return(inner_service_success)
105+
allow(instance).to receive(:bar).and_return(inner_service_error)
106+
end
107+
108+
it { expect(instance.()).to be_failed }
109+
it { expect(instance.().errors).to eq(["Inner error"]) }
110+
end
111+
end
112+
end
113+
114+
describe ".before_error" do
115+
before do
116+
allow(instance).to receive(:inspect)
117+
allow(instance).to receive(:foo).and_return(inner_service_error)
118+
119+
service.before_error { inspect }
120+
121+
instance.()
122+
end
123+
124+
it { expect(instance).to have_received(:inspect).once }
125+
end
126+
end

0 commit comments

Comments
 (0)