Skip to content

Commit

Permalink
Merge pull request #2 from Kaligo/feature/result-monad
Browse files Browse the repository at this point in the history
[Feature] Add ResultMonad mixin
  • Loading branch information
Drenmi authored Mar 8, 2021
2 parents bde3298 + 429d900 commit c0785c6
Show file tree
Hide file tree
Showing 8 changed files with 283 additions and 2 deletions.
3 changes: 3 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ Metrics/BlockLength:
Exclude:
- "spec/**/*"

Style/Alias:
EnforcedStyle: prefer_alias_method

Style/Documentation:
Enabled: false

Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

## 0.3.0

- Add `ResultMonad` mixin.

## 0.2.0

- Add `OptionsDeclaration` mixin.
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.1.0)
stimpack (0.3.0)
activesupport (~> 6.1)

GEM
Expand Down
56 changes: 56 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and behaviour.

- [FunctionalObject](#functionalobject)
- [OptionsDeclaration](#optionsdeclaration)
- [ResultMonad](#resultmonad)

## FunctionalObject

Expand Down Expand Up @@ -84,3 +85,58 @@ When declaring an option, the following configuration kets are available:
| `default` | `any` | `nil` | Can be a literal or a callable object. Arrays and hashes will not be shared across instances. |
| `required` | `boolean` | `true` | |
| `private_reader` | `boolean` | `true` | |

## ResultMonad

A mixin that is used to return structured result objects from a method. The
result will be either `successful` or `failed`, and the caller can take
whatever action they consider appropriate based on the outcome.

From within the class, the instance methods `#success` and `#error`,
respectively, can be used to construct the result object.

**Example:**

```ruby
class Foo
include Stimpack::ResultMonad

blank_result

def call
return error(errors: "Whoops!") if operation_failed?

success
end
end
```

Successful results can optionally be parameterized with additional data using
the `#result` method. The declared result key will be required to be passed to
the `#success` constructor method.

**Example:**

```ruby
class Foo
include Stimpack::ResultMonad

result :bar

def call
success(bar: "It worked!")
end
end
```

Consumers of the class can then decide what to do based on the outcome:

```ruby
result = Foo.new.()

if result.successful?
result.bar
else
result.errors
end
```
1 change: 1 addition & 0 deletions lib/stimpack.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

require_relative "stimpack/functional_object"
require_relative "stimpack/options_declaration"
require_relative "stimpack/result_monad"

module Stimpack
class Error < StandardError; end
Expand Down
110 changes: 110 additions & 0 deletions lib/stimpack/result_monad.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# frozen_string_literal: true

module Stimpack
module ResultMonad
class IncompatibleResultError < ArgumentError; end

module ClassMethods
# Used to declare the structure of the result object in the case of a
# successful invocation, e.g.:
#
# class StoreMembership
# result :membership
# end
#
def result(attribute = nil)
raise ArgumentError, "Use `#blank_result` to declare an empty service result" if attribute.nil?

@result_key = attribute

build_result_struct(attribute)
end

# Used to declare a result object which does not carry any data, e.g.:
#
# class DeliverEmail
# blank_result
# end
#
def blank_result(attribute = nil)
raise ArgumentError, "Use `#result` to declare a non-empty service result" unless attribute.nil?

build_result_struct
end

def blank_result?
result_key.nil?
end

instance_eval do
attr_reader :result_key
attr_reader :result_struct
end

protected

def build_result_struct(attribute = nil)
attributes = [:errors, attribute].compact

@result_struct = Struct.new(*attributes, keyword_init: true) do
def successful?
errors.nil?
end

def failed?
!successful?
end
end.freeze
end
end

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

private

# To be called from within an object when its invocation is successful, e.g.:
#
# if membership.save
# success(membership: membership)
# end
#
# The arguments are defined by the class' `result` declaration. (See above.)
#
def success(**result_attributes)
raise incompatible_result_error(result_attributes.keys) unless compatible_result?(**result_attributes)

self.class.result_struct.new(**result_attributes)
end

# To be called from within an object when its invocation fails.
#
def error(errors:)
self.class.result_struct.new(errors: errors)
end
alias_method :error_with, :error

# Check that the key passed to `#success` is the key that is declared for this
# service class. (Or that nothing is passed in the case the service class has
# declared a blank result.)
#
def compatible_result?(**result_attributes)
return true if result_attributes.nil? || result_attributes.empty? && self.class.blank_result?

result_attributes.keys == [self.class.result_key]
end

def incompatible_result_error(actual_attributes)
IncompatibleResultError.new(<<~MESSAGE)
Expected result to be constructed with:
#{self.class.blank_result? ? 'no attributes' : self.class.result_key}
But it was constructed with:
#{actual_attributes.empty? ? 'no attributes' : actual_attributes}
MESSAGE
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.1.0"
VERSION = "0.3.0"
end
107 changes: 107 additions & 0 deletions spec/stimpack/result_monad_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# frozen_string_literal: true

require "stimpack/result_monad"

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

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

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

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

describe ".result" do
context "when declaring a blank result" do
it { expect { service.result }.to raise_error(ArgumentError) }
end

context "when declaring a result key" do
it { expect { service.result(:foo) }.not_to raise_error }
end
end

describe ".blank_result" do
context "when declaring a blank result" do
it { expect { service.blank_result }.not_to raise_error }
end

context "when declaring a result key" do
it { expect { service.blank_result(:foo) }.to raise_error(ArgumentError) }
end
end

describe ".blank_result?" do
context "when a blank result is declared" do
before { service.blank_result }

it { expect(service).to be_blank_result }
end

context "when a result key is declared" do
before { service.result(:foo) }

it { expect(service).not_to be_blank_result }
end
end

describe ".result_key" do
context "when a result key is declared" do
before { service.result(:foo) }

it { expect(service.result_key).to eq(:foo) }
end

context "when a blank result is declared" do
before { service.blank_result }

it { expect(service.result_key).to eq(nil) }
end
end

describe ".result_struct" do
context "when a result key is declared" do
before { service.result(:foo) }

it { expect(service.result_struct.members).to contain_exactly(:foo, :errors) }
end

context "when a blank result is declared" do
before { service.blank_result }

it { expect(service.result_struct.members).to contain_exactly(:errors) }
end
end

describe "#success" do
before { service.result(:foo) }

let(:instance) { service.new }

context "when arguments match declared result" do
it { expect(instance.success_result(foo: "bar")).to be_successful }
it { expect(instance.success_result(foo: "bar").foo).to eq("bar") }
end

context "when arguments don't match declared result" do
it { expect { instance.success_result(bar: "baz") }.to raise_error(described_class::IncompatibleResultError) }
end
end

describe "#error" do
before { service.result(:foo) }

let(:instance) { service.new }

it { expect(instance.error_result(errors: ["foo"])).to be_failed }
it { expect(instance.error_result(errors: ["foo"]).errors).to eq ["foo"] }
end
end

0 comments on commit c0785c6

Please sign in to comment.