Skip to content

Commit

Permalink
Basic DSL to define operations that can fail
Browse files Browse the repository at this point in the history
We introduce a thin DSL on top of dry-monads' result type [1] 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://dry-rb.org/gems/dry-monads/1.6/result/
[2] https://en.wikibooks.org/wiki/Haskell/do_notation
  • Loading branch information
waiting-for-dev committed Oct 3, 2023
1 parent 40ea063 commit f43ef25
Show file tree
Hide file tree
Showing 4 changed files with 173 additions and 3 deletions.
1 change: 1 addition & 0 deletions dry-operation.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Gem::Specification.new do |spec|

spec.required_ruby_version = ">= 3.0.0"
spec.add_dependency "zeitwerk", "~> 2.6"
spec.add_dependency "dry-monads", "~> 1.6"

spec.extra_rdoc_files = Dir["README*", "LICENSE*"]
spec.files = Dir["*.gemspec", "lib/**/*"]
Expand Down
74 changes: 71 additions & 3 deletions lib/dry/operation.rb
Original file line number Diff line number Diff line change
@@ -1,10 +1,59 @@
# frozen_string_literal: true

require "zeitwerk"
require "dry/monads"

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

def self.loader
@loader ||= Zeitwerk::Loader.new.tap do |loader|
root = File.expand_path "..", __dir__
Expand All @@ -13,7 +62,26 @@ def self.loader
loader.push_dir root
end
end

loader.setup

# 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)
result.value_or { throw :halt, result }
end
end
end
43 changes: 43 additions & 0 deletions spec/integration/operations_spec.rb
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
58 changes: 58 additions & 0 deletions spec/unit/dry/operation_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# 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

# Make sure we don't use pattern matching to extract the value, as that
# would be a problem with a value that is an array. See
# https://https://github.com/dry-rb/dry-monads/issues/173
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

0 comments on commit f43ef25

Please sign in to comment.