-
-
Notifications
You must be signed in to change notification settings - Fork 6
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Basic DSL to define operations that can fail
We introduce a thin DSL on top of dry-monads' result type to define operations that can fail. `Dry::Operation#steps` accepts a block where individual operations can be called with `#step`. When they return a `Success`, the inner value is automatically unwrapped, ready to be consumed by subsequen steps. When a `Failure` is returned along the way, the remaining steps are skipped and the failure is returned. Example: ```ruby require "dry/operation" class MyOperation < Dry::Operation def call(input) steps do attrs = step validate(input) user = step persist(attrs) step notify(user) user end end def validate(input) # Dry::Monads::Result::Success or Dry::Monads::Result::Failure end def persist(attrs) # Dry::Monads::Result::Success or Dry::Monads::Result::Failure end def notify(user) # Dry::Monads::Result::Success or Dry::Monads::Result::Failure end end include Dry::Monads[:result] case MyOperation.new.call(input) in Success(user) puts "User #{user.name} created" in Failure[:invalid_input, validation_errors] puts "Invalid input: #{validation_errors}" in Failure(:database_error) puts "Database error" in Failure(:email_error) puts "Email error" end ``` The approach is similar to the so-called "do notation" in Haskell [1], but done in an idiomatic Ruby way. There's no magic happening between every line within the block (i.e., "programmable semicolons"). Besides not being something possible in Ruby, it'd be very confusing for people to require all the lines to return a `Result` type (e.g., we want to allow debugging). Instead, it's required to unwrap intermediate results through the `step` method. Notice that not having logic to magically unwrap results is also intentional to allow flexibility to transform results in between steps (e.g., `validate(input).value_or({})`) [1] https://en.wikibooks.org/wiki/Haskell/do_notation
- Loading branch information
1 parent
9bfa383
commit d646a10
Showing
4 changed files
with
178 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,14 +1,89 @@ | ||
# frozen_string_literal: true | ||
|
||
require "zeitwerk" | ||
require "dry/monads" | ||
|
||
Zeitwerk::Loader.new.then do |loader| | ||
loader.push_dir "#{__dir__}/.." | ||
loader.setup | ||
end | ||
|
||
module Dry | ||
# Main namespace. | ||
module Operation | ||
# DSL for chaining operations that can fail | ||
# | ||
# {Dry::Operation} is a thin DSL wrapping dry-monads that allows you to chain | ||
# operations by focusing on the happy path and short-circuiting on failure. | ||
# | ||
# The entry-point for defining your operations flow is {#steps}. It accepts a | ||
# block where you can call individual operations through {#step}. Operations | ||
# need to return either a success or a failure result. Successful results will | ||
# be automatically unwrapped, while a failure will stop further execution of | ||
# the block. | ||
# | ||
# @example | ||
# class MyOperation < Dry::Operation | ||
# def call(input) | ||
# steps do | ||
# attrs = step validate(input) | ||
# user = step persist(attrs) | ||
# step notify(user) | ||
# user | ||
# end | ||
# end | ||
# | ||
# def validate(input) | ||
# # Dry::Monads::Result::Success or Dry::Monads::Result::Failure | ||
# end | ||
# | ||
# def persist(attrs) | ||
# # Dry::Monads::Result::Success or Dry::Monads::Result::Failure | ||
# end | ||
# | ||
# def notify(user) | ||
# # Dry::Monads::Result::Success or Dry::Monads::Result::Failure | ||
# end | ||
# end | ||
# | ||
# include Dry::Monads[:result] | ||
# | ||
# case MyOperation.new.call(input) | ||
# in Success(user) | ||
# puts "User #{user.name} created" | ||
# in Failure[:invalid_input, validation_errors] | ||
# puts "Invalid input: #{validation_errors}" | ||
# in Failure(:database_error) | ||
# puts "Database error" | ||
# in Failure(:email_error) | ||
# puts "Email error" | ||
# end | ||
class Operation | ||
include Dry::Monads::Result::Mixin | ||
|
||
# Wraps block's return value in a {Success} | ||
# | ||
# Catches :halt and returns it | ||
# | ||
# @yieldreturn [Object] | ||
# @return [Dry::Monads::Result::Success] | ||
# @see #step | ||
def steps(&block) | ||
catch(:halt) { Success(block.call) } | ||
end | ||
|
||
# Unwrapps a {Success} or throws :halt with a {Failure} | ||
# | ||
# @param result [Dry::Monads::Result] | ||
# @return [Object] wrapped value | ||
# @see #steps | ||
def step(result) | ||
case result | ||
in Success | ||
# TODO: Extract value through pattern matching when | ||
# https://github.com/dry-rb/dry-monads/issues/173 is fixed | ||
result.value! | ||
in Failure | ||
throw :halt, result | ||
end | ||
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 |
---|---|---|
@@ -0,0 +1,43 @@ | ||
# frozen_string_literal: true | ||
|
||
require "spec_helper" | ||
|
||
RSpec.describe "Operations" do | ||
include Dry::Monads[:result] | ||
|
||
it "chains successful operations and returns wrapping in a Success" do | ||
klass = Class.new(Dry::Operation) do | ||
def add_one_then_two(x) | ||
steps do | ||
y = step add_one(x) | ||
step add_two(y) | ||
end | ||
end | ||
|
||
def add_one(x) = Success(x + 1) | ||
def add_two(x) = Success(x + 2) | ||
end | ||
|
||
expect( | ||
klass.new.add_one_then_two(1) | ||
).to eq(Success(4)) | ||
end | ||
|
||
it "short-circuits on Failure and returns it" do | ||
klass = Class.new(Dry::Operation) do | ||
def divide_by_zero_then_add_one(x) | ||
steps do | ||
y = step divide_by_zero(x) | ||
step inc(y) | ||
end | ||
end | ||
|
||
def divide_by_zero(_x) = Failure(:not_possible) | ||
def add_one(x) = Success(x + 1) | ||
end | ||
|
||
expect( | ||
klass.new.divide_by_zero_then_add_one(1) | ||
).to eq(Failure(:not_possible)) | ||
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 |
---|---|---|
@@ -0,0 +1,57 @@ | ||
# frozen_string_literal: true | ||
|
||
require "spec_helper" | ||
|
||
RSpec.describe Dry::Operation do | ||
include Dry::Monads[:result] | ||
|
||
describe "#steps" do | ||
it "wraps block's return value in a Success" do | ||
klass = Class.new(described_class) do | ||
def foo(value) | ||
steps { value } | ||
end | ||
end | ||
|
||
result = klass.new.foo(:foo) | ||
|
||
expect(result).to eq(Success(:foo)) | ||
end | ||
|
||
it "catches :halt and returns it" do | ||
klass = Class.new(described_class) do | ||
def foo(value) | ||
steps { throw :halt, value } | ||
end | ||
end | ||
|
||
result = klass.new.foo(:foo) | ||
|
||
expect(result).to be(:foo) | ||
end | ||
end | ||
|
||
describe "#step" do | ||
it "returns wrapped value when given a success" do | ||
expect( | ||
described_class.new.step(Success(:foo)) | ||
).to be(:foo) | ||
end | ||
|
||
# TODO: Remove on https://github.com/dry-rb/dry-monads/issues/173 is not an | ||
# issue | ||
it "is able to extract an array from a success result" do | ||
expect( | ||
described_class.new.step(Success([:foo])) | ||
).to eq([:foo]) | ||
end | ||
|
||
it "throws :halt with the result when given a failure" do | ||
failure = Failure(:foo) | ||
|
||
expect { | ||
described_class.new.step(failure) | ||
}.to throw_symbol(:halt, failure) | ||
end | ||
end | ||
end |