Skip to content

Commit

Permalink
Avoid #steps boilerplate by prepending around #call
Browse files Browse the repository at this point in the history
We get rid of the necessity to wrap the operations' flow with the
`#steps`'s block by prepending a module that decorates the `#call`
method.

If before we had to do:

```ruby
class CreateUser < Dry::Operation
  def call(input)
    steps do
      attributes = step validate(input)
      step create(attributes)
    end
  end

  # ...
end
```

Now we can do:

```ruby
class CreateUser < Dry::Operation
  def call(input)
    attributes = step validate(input)
    step create(attributes)
  end

  # ...
end
```

We want to provide that as the default behavior to improve the
ergonomics of the library. However, we also want to provide a way to
customize or opt out of this magic behavior.

After discarding dynamic inheritance because of DX concerns (see
#9), we opt for implementing
a couple of class-level methods to tweak the defaults.

`.operate_on` allows to customize the method to decorate. E.g., this is
how we decorate `#run` instead of `#call`:

```ruby
class CreateUser < Dry::Operation
  operate_on :run # Several methods can be passed as arguments

  def run(input)
    attributes = step validate(input)
    step create(attributes)
  end

  # ...
end
```

On the other hand, `.skip_prepending` allows to opt out of the default
`#call` decoration:

```ruby
class CreateUser < Dry::Operation
  skip_prepending

  def call(input)
    steps do
      attributes = step validate(input)
      step create(attributes)
    end
  end

  # ...
end
```

To have `#call` decorated by default but still be something
configurable, we need to rely on Ruby's `.method_added` hook. Notice
that for any other method specified by `.operate_on` we could just
include the prepender module and avoid going through the hook. However,
we opt for still using the hook to have a single way of doing things.

Both `.operate_on` and `.skip_prepending` tweaks are inherited by
subclasses, so it's possible to do something like:

```ruby
class BaseOperation < Dry::Operation
  operate_on :run
end

class CreateUser < BaseOperation
  def run(input)
    attributes = step validate(input)
    step create(attributes)
  end

  # ...
end
```

Both methods raise an exception when called after any method has been
prepended. This is to avoid misunderstandings like trying to skip
prepending after the `.method_added` hook has been triggered:

```ruby
class CreateUser < Dry::Operation
  def call(input)
    steps do
      attributes = step validate(input)
      step create(attributes)
    end
  end
  skip_prepending # At this point, `#call` would have already been prepended

  # ...
end
```

Similarly, `.operate_on` raises an exception when called after the
method has already been defined.

```ruby
class CreateUser < Dry::Operation
  def run(input)
    attributes = step validate(input)
    step create(attributes)
  end
  operate_on :run # At this point, `.method_added` won't be called for `#run`

  # ...
end
```

Those checks are reset when a subclass is defined to allow for
redefinitions or changes in the configuration.
  • Loading branch information
waiting-for-dev committed Oct 24, 2023
1 parent 48eebad commit e94dc18
Show file tree
Hide file tree
Showing 7 changed files with 493 additions and 46 deletions.
1 change: 1 addition & 0 deletions .yardopts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
--markup markdown
139 changes: 93 additions & 46 deletions lib/dry/operation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,62 +9,107 @@ module Dry
# {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
# The canonical way of using it is to subclass {Dry::Operation} and define
# your flow in the `#call` method. Individual operations can be called with
# {#step}. They 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 method.
#
# ```ruby
# class MyOperation < Dry::Operation
# def call(input)
# attrs = step validate(input)
# user = step persist(attrs)
# step notify(user)
# user
# 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
# ```
#
# Under the hood, the `#call` method is decorated to allow skipping the rest
# of its execution when a failure is encountered. You can choose to use another
# method with {ClassContext#operate_on}:
#
# ```ruby
# class MyOperation < Dry::Operation
# operate_on :run
#
# def run(input)
# attrs = step validate(input)
# user = step persist(attrs)
# step notify(user)
# user
# end
#
# # ...
# end
# ```
#
# You can opt out altogether of this behavior via {ClassContext#skip_prepending}. If so,
# you manually need to wrap your flow within the {#steps} method.
#
# ```ruby
# class MyOperation < Dry::Operation
# skip_prepending
#
# def call(input)
# steps do
# attrs = step validate(input)
# user = step persist(attrs)
# step notify(user)
# user
# end
# end
#
# # ...
# end
# ```
#
# The behavior configured by {ClassContext#operate_on} and {ClassContext#skip_prepending} is
# inherited by subclasses.
class Operation
include Dry::Monads::Result::Mixin

def self.loader
@loader ||= Zeitwerk::Loader.new.tap do |loader|
root = File.expand_path "..", __dir__
loader.inflector = Zeitwerk::GemInflector.new("#{root}/dry/operation.rb")
loader.tag = "dry-operation"
loader.push_dir root
loader.ignore(
"#{root}/dry/operation/errors.rb"
)
end
end
loader.setup

# Wraps block's return value in a {Success}
extend ClassContext
include Dry::Monads::Result::Mixin

# Wraps block's return value in a {Dry::Monads::Result::Success}
#
# Catches :halt and returns it
#
Expand All @@ -75,7 +120,9 @@ def steps(&block)
catch(:halt) { Success(block.call) }
end

# Unwrapps a {Success} or throws :halt with a {Failure}
# Unwraps a {Dry::Monads::Result::Success}
#
# Throws :halt with a {Dry::Monads::Result::Failure} on failure.
#
# @param result [Dry::Monads::Result]
# @return [Object] wrapped value
Expand Down
82 changes: 82 additions & 0 deletions lib/dry/operation/class_context.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# frozen_string_literal: true

module Dry
class Operation
# {Dry::Operation} class context
module ClassContext
# Default methods to be prepended unless changed via {.operate_on}
DEFAULT_METHODS_TO_PREPEND = [:call].freeze

# Configures the instance methods to be prepended
#
# The given methods will be prepended with a wrapper that calls {#steps}
# before calling the original method.
#
# This method must be called before defining any of the methods to be
# prepended or before prepending any other method.
#
# @param methods [Array<Symbol>] methods to prepend
# @raise [MethodsToPrependAlreadyDefinedError] if any of the methods have
# already been defined in self
# @raise [PrependConfigurationError] if there's already a prepended method
def operate_on(*methods)
@_mutex.synchronize do
@_prepend_manager = @_prepend_manager.register(*methods)
end
end

# Skips prepending any method
#
# This method must be called before any method is prepended.
#
# @raise [PrependConfigurationError] if there's already a prepended method
def skip_prepending
@_mutex.synchronize do
@_prepend_manager = @_prepend_manager.void
end
end

# @api private
def inherited(klass)
super
klass.instance_variable_set(:@_mutex, Mutex.new)
if klass.superclass == Dry::Operation
ClassContext.directly_inherited(klass)
else
ClassContext.indirectly_inherited(klass)
end
end

# @api private
def self.directly_inherited(klass)
klass.extend(MethodAddedHook)
klass.instance_variable_set(
:@_prepend_manager,
PrependManager.new(klass: klass, methods_to_prepend: DEFAULT_METHODS_TO_PREPEND)
)
end

# @api private
def self.indirectly_inherited(klass)
klass.instance_variable_set(
:@_prepend_manager,
klass.superclass.instance_variable_get(:@_prepend_manager).with(
klass: klass,
prepended_methods: []
)
)
end

# @api private
module MethodAddedHook
def method_added(method)
super

@_mutex.synchronize do
@_prepend_manager = @_prepend_manager.call(method: method)
end
end
end
end
end
end
31 changes: 31 additions & 0 deletions lib/dry/operation/class_context/method_prepender.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# frozen_string_literal: true

module Dry
class Operation
module ClassContext
# @api private
class MethodPrepender < Module
def initialize(method:)
super()
@method = method
end

def included(klass)
klass.prepend(mod)
end

private

def mod
@module ||= Module.new.tap do |mod|
mod.define_method(@method) do |*args, **kwargs, &block|
steps do
super(*args, **kwargs, &block)
end
end
end
end
end
end
end
end
62 changes: 62 additions & 0 deletions lib/dry/operation/class_context/prepend_manager.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# frozen_string_literal: true

require "dry/operation/errors"

module Dry
class Operation
module ClassContext
# @api private
class PrependManager
def initialize(klass:, methods_to_prepend:, prepended_methods: [])
@klass = klass
@methods_to_prepend = methods_to_prepend
@prepended_methods = prepended_methods
end

def register(*methods)
ensure_pristine

already_defined_methods = methods & @klass.instance_methods(false)
if already_defined_methods.empty?
with(methods_to_prepend: methods)
else
raise MethodsToPrependAlreadyDefinedError.new(methods: already_defined_methods)
end
end

def void
ensure_pristine

with(methods_to_prepend: [])
end

def with(
klass: @klass,
methods_to_prepend: @methods_to_prepend,
prepended_methods: @prepended_methods
)
self.class.new(
klass: klass,
methods_to_prepend: methods_to_prepend,
prepended_methods: prepended_methods
)
end

def call(method:)
return self unless @methods_to_prepend.include?(method)

@klass.include(MethodPrepender.new(method: method))
with(prepended_methods: @prepended_methods + [method])
end

private

def ensure_pristine
return if @prepended_methods.empty?

raise PrependConfigurationError.new(methods: @prepended_methods)
end
end
end
end
end
26 changes: 26 additions & 0 deletions lib/dry/operation/errors.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# frozen_string_literal: true

module Dry
class Operation
# Methods to prepend have already been defined
class MethodsToPrependAlreadyDefinedError < ::StandardError
def initialize(methods:)
super <<~MSG
'.operate_on' must be called before the given methods are defined.
The following methods have already been defined: #{methods.join(", ")}
MSG
end
end

# Configuring prepending after a method has already been prepended
class PrependConfigurationError < ::StandardError
def initialize(methods:)
super <<~MSG
'.operate_on' and '.skip_prepending' can't be called after any methods\
in the class have already been prepended.
The following methods have already been prepended: #{methods.join(", ")}
MSG
end
end
end
end
Loading

0 comments on commit e94dc18

Please sign in to comment.