-
-
Notifications
You must be signed in to change notification settings - Fork 7
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Avoid
#steps
boilerplate by prepending around #call
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
1 parent
48eebad
commit e94dc18
Showing
7 changed files
with
493 additions
and
46 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
--markup markdown |
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 |
---|---|---|
@@ -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 |
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,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 |
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,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 |
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,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 |
Oops, something went wrong.