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 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
waiting-for-dev committed Oct 2, 2023
1 parent 9bfa383 commit d646a10
Show file tree
Hide file tree
Showing 4 changed files with 178 additions and 2 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
79 changes: 77 additions & 2 deletions lib/dry/operation.rb
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
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
57 changes: 57 additions & 0 deletions spec/unit/dry/operation_spec.rb
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

0 comments on commit d646a10

Please sign in to comment.