Skip to content

Commit d8813a8

Browse files
Avoid #steps boilerplate by prepending around #call (#11)
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.
1 parent 6d32b22 commit d8813a8

File tree

7 files changed

+513
-46
lines changed

7 files changed

+513
-46
lines changed

.yardopts

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
--markup markdown

lib/dry/operation.rb

+93-46
Original file line numberDiff line numberDiff line change
@@ -9,62 +9,107 @@ module Dry
99
# {Dry::Operation} is a thin DSL wrapping dry-monads that allows you to chain
1010
# operations by focusing on the happy path and short-circuiting on failure.
1111
#
12-
# The entry-point for defining your operations flow is {#steps}. It accepts a
13-
# block where you can call individual operations through {#step}. Operations
14-
# need to return either a success or a failure result. Successful results will
15-
# be automatically unwrapped, while a failure will stop further execution of
16-
# the block.
17-
#
18-
# @example
19-
# class MyOperation < Dry::Operation
20-
# def call(input)
21-
# steps do
22-
# attrs = step validate(input)
23-
# user = step persist(attrs)
24-
# step notify(user)
25-
# user
26-
# end
27-
# end
28-
#
29-
# def validate(input)
30-
# # Dry::Monads::Result::Success or Dry::Monads::Result::Failure
31-
# end
32-
#
33-
# def persist(attrs)
34-
# # Dry::Monads::Result::Success or Dry::Monads::Result::Failure
35-
# end
36-
#
37-
# def notify(user)
38-
# # Dry::Monads::Result::Success or Dry::Monads::Result::Failure
39-
# end
40-
# end
41-
#
42-
# include Dry::Monads[:result]
43-
#
44-
# case MyOperation.new.call(input)
45-
# in Success(user)
46-
# puts "User #{user.name} created"
47-
# in Failure[:invalid_input, validation_errors]
48-
# puts "Invalid input: #{validation_errors}"
49-
# in Failure(:database_error)
50-
# puts "Database error"
51-
# in Failure(:email_error)
52-
# puts "Email error"
53-
# end
12+
# The canonical way of using it is to subclass {Dry::Operation} and define
13+
# your flow in the `#call` method. Individual operations can be called with
14+
# {#step}. They need to return either a success or a failure result.
15+
# Successful results will be automatically unwrapped, while a failure will
16+
# stop further execution of the method.
17+
#
18+
# ```ruby
19+
# class MyOperation < Dry::Operation
20+
# def call(input)
21+
# attrs = step validate(input)
22+
# user = step persist(attrs)
23+
# step notify(user)
24+
# user
25+
# end
26+
#
27+
# def validate(input)
28+
# # Dry::Monads::Result::Success or Dry::Monads::Result::Failure
29+
# end
30+
#
31+
# def persist(attrs)
32+
# # Dry::Monads::Result::Success or Dry::Monads::Result::Failure
33+
# end
34+
#
35+
# def notify(user)
36+
# # Dry::Monads::Result::Success or Dry::Monads::Result::Failure
37+
# end
38+
# end
39+
#
40+
# include Dry::Monads[:result]
41+
#
42+
# case MyOperation.new.call(input)
43+
# in Success(user)
44+
# puts "User #{user.name} created"
45+
# in Failure[:invalid_input, validation_errors]
46+
# puts "Invalid input: #{validation_errors}"
47+
# in Failure(:database_error)
48+
# puts "Database error"
49+
# in Failure(:email_error)
50+
# puts "Email error"
51+
# end
52+
# ```
53+
#
54+
# Under the hood, the `#call` method is decorated to allow skipping the rest
55+
# of its execution when a failure is encountered. You can choose to use another
56+
# method with {ClassContext#operate_on}:
57+
#
58+
# ```ruby
59+
# class MyOperation < Dry::Operation
60+
# operate_on :run
61+
#
62+
# def run(input)
63+
# attrs = step validate(input)
64+
# user = step persist(attrs)
65+
# step notify(user)
66+
# user
67+
# end
68+
#
69+
# # ...
70+
# end
71+
# ```
72+
#
73+
# You can opt out altogether of this behavior via {ClassContext#skip_prepending}. If so,
74+
# you manually need to wrap your flow within the {#steps} method.
75+
#
76+
# ```ruby
77+
# class MyOperation < Dry::Operation
78+
# skip_prepending
79+
#
80+
# def call(input)
81+
# steps do
82+
# attrs = step validate(input)
83+
# user = step persist(attrs)
84+
# step notify(user)
85+
# user
86+
# end
87+
# end
88+
#
89+
# # ...
90+
# end
91+
# ```
92+
#
93+
# The behavior configured by {ClassContext#operate_on} and {ClassContext#skip_prepending} is
94+
# inherited by subclasses.
5495
class Operation
55-
include Dry::Monads::Result::Mixin
56-
5796
def self.loader
5897
@loader ||= Zeitwerk::Loader.new.tap do |loader|
5998
root = File.expand_path "..", __dir__
6099
loader.inflector = Zeitwerk::GemInflector.new("#{root}/dry/operation.rb")
61100
loader.tag = "dry-operation"
62101
loader.push_dir root
102+
loader.ignore(
103+
"#{root}/dry/operation/errors.rb"
104+
)
63105
end
64106
end
65107
loader.setup
66108

67-
# Wraps block's return value in a {Success}
109+
extend ClassContext
110+
include Dry::Monads::Result::Mixin
111+
112+
# Wraps block's return value in a {Dry::Monads::Result::Success}
68113
#
69114
# Catches :halt and returns it
70115
#
@@ -75,7 +120,9 @@ def steps(&block)
75120
catch(:halt) { Success(block.call) }
76121
end
77122

78-
# Unwrapps a {Success} or throws :halt with a {Failure}
123+
# Unwraps a {Dry::Monads::Result::Success}
124+
#
125+
# Throws :halt with a {Dry::Monads::Result::Failure} on failure.
79126
#
80127
# @param result [Dry::Monads::Result]
81128
# @return [Object] wrapped value

lib/dry/operation/class_context.rb

+72
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# frozen_string_literal: true
2+
3+
module Dry
4+
class Operation
5+
# {Dry::Operation} class context
6+
module ClassContext
7+
# Default methods to be prepended unless changed via {.operate_on}
8+
DEFAULT_METHODS_TO_PREPEND = [:call].freeze
9+
10+
# Configures the instance methods to be prepended
11+
#
12+
# The given methods will be prepended with a wrapper that calls {#steps}
13+
# before calling the original method.
14+
#
15+
# This method must be called before defining any of the methods to be
16+
# prepended or before prepending any other method.
17+
#
18+
# @param methods [Array<Symbol>] methods to prepend
19+
# @raise [MethodsToPrependAlreadyDefinedError] if any of the methods have
20+
# already been defined in self
21+
# @raise [PrependConfigurationError] if there's already a prepended method
22+
def operate_on(*methods)
23+
@_prepend_manager.register(*methods)
24+
end
25+
26+
# Skips prepending any method
27+
#
28+
# This method must be called before any method is prepended.
29+
#
30+
# @raise [PrependConfigurationError] if there's already a prepended method
31+
def skip_prepending
32+
@_prepend_manager.void
33+
end
34+
35+
# @api private
36+
def inherited(klass)
37+
super
38+
if klass.superclass == Dry::Operation
39+
ClassContext.directly_inherited(klass)
40+
else
41+
ClassContext.indirectly_inherited(klass)
42+
end
43+
end
44+
45+
# @api private
46+
def self.directly_inherited(klass)
47+
klass.extend(MethodAddedHook)
48+
klass.instance_variable_set(
49+
:@_prepend_manager,
50+
PrependManager.new(klass: klass, methods_to_prepend: DEFAULT_METHODS_TO_PREPEND)
51+
)
52+
end
53+
54+
# @api private
55+
def self.indirectly_inherited(klass)
56+
klass.instance_variable_set(
57+
:@_prepend_manager,
58+
klass.superclass.instance_variable_get(:@_prepend_manager).for_subclass(klass)
59+
)
60+
end
61+
62+
# @api private
63+
module MethodAddedHook
64+
def method_added(method)
65+
super
66+
67+
@_prepend_manager.call(method: method)
68+
end
69+
end
70+
end
71+
end
72+
end
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# frozen_string_literal: true
2+
3+
require "dry/operation/errors"
4+
5+
module Dry
6+
class Operation
7+
module ClassContext
8+
# @api private
9+
class PrependManager
10+
def initialize(klass:, methods_to_prepend:, prepended_methods: [])
11+
@klass = klass
12+
@methods_to_prepend = methods_to_prepend
13+
@prepended_methods = prepended_methods
14+
end
15+
16+
def register(*methods)
17+
ensure_pristine
18+
19+
already_defined_methods = methods & @klass.instance_methods(false)
20+
if already_defined_methods.any?
21+
raise MethodsToPrependAlreadyDefinedError.new(methods: already_defined_methods)
22+
else
23+
@methods_to_prepend = methods
24+
end
25+
end
26+
27+
def void
28+
ensure_pristine
29+
30+
@methods_to_prepend = []
31+
end
32+
33+
def call(method:)
34+
return self unless @methods_to_prepend.include?(method)
35+
36+
@klass.include(StepsMethodPrepender.new(method: method))
37+
@prepended_methods += [method]
38+
end
39+
40+
def for_subclass(subclass)
41+
self.class.new(
42+
klass: subclass,
43+
methods_to_prepend: @methods_to_prepend.dup,
44+
prepended_methods: []
45+
)
46+
end
47+
48+
private
49+
50+
def ensure_pristine
51+
return if @prepended_methods.empty?
52+
53+
raise PrependConfigurationError.new(methods: @prepended_methods)
54+
end
55+
end
56+
end
57+
end
58+
end
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# frozen_string_literal: true
2+
3+
module Dry
4+
class Operation
5+
module ClassContext
6+
# @api private
7+
class StepsMethodPrepender < Module
8+
def initialize(method:)
9+
super()
10+
@method = method
11+
end
12+
13+
def included(klass)
14+
klass.prepend(mod)
15+
end
16+
17+
private
18+
19+
def mod
20+
@module ||= Module.new.tap do |mod|
21+
mod.define_method(@method) do |*args, **kwargs, &block|
22+
steps do
23+
super(*args, **kwargs, &block)
24+
end
25+
end
26+
end
27+
end
28+
end
29+
end
30+
end
31+
end

lib/dry/operation/errors.rb

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# frozen_string_literal: true
2+
3+
module Dry
4+
class Operation
5+
# Methods to prepend have already been defined
6+
class MethodsToPrependAlreadyDefinedError < ::StandardError
7+
def initialize(methods:)
8+
super <<~MSG
9+
'.operate_on' must be called before the given methods are defined.
10+
The following methods have already been defined: #{methods.join(", ")}
11+
MSG
12+
end
13+
end
14+
15+
# Configuring prepending after a method has already been prepended
16+
class PrependConfigurationError < ::StandardError
17+
def initialize(methods:)
18+
super <<~MSG
19+
'.operate_on' and '.skip_prepending' can't be called after any methods\
20+
in the class have already been prepended.
21+
The following methods have already been prepended: #{methods.join(", ")}
22+
MSG
23+
end
24+
end
25+
end
26+
end

0 commit comments

Comments
 (0)