-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #2 from Kaligo/feature/result-monad
[Feature] Add ResultMonad mixin
- Loading branch information
Showing
8 changed files
with
283 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |