Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,14 @@ and this project adheres to [Break Versioning](https://www.taoensso.com/break-ve

### Added

- `#step` now accepts an optional step name as its first argument (e.g. `step :validate, validate(input)`). When given, the name is forwarded to `#on_failure` via a new `step_name:` kwarg if the step fails, so a single `on_failure` hook can branch on which step failed. (@timriley in #42)
- `#on_failure` now supports `step_name:` and `method_name:` keyword arguments. Hooks can opt in to either or both — e.g. `def on_failure(failure, step_name:)`, `def on_failure(failure, method_name:)`, or `def on_failure(failure, step_name:, method_name:)`. The existing positional params signatures are unchanged: `def on_failure(failure)` and `def on_failure(failure, method_name)`. (@timriley in #42)

### Changed

- `#steps` now dispatches to `#on_failure` itself, so users who use `skip_prepending` and call `steps do ... end` manually get the same `#on_failure` routing as the auto-prepended case. Previously `#on_failure` only fired via the prepender. (@timriley in #42)
- The Validation extension forwards `:validation` via the `step_name:` kwarg to `#on_failure` when contract validation fails, so users can distinguish contract failures from other named steps. (@timriley in #42)

### Deprecated

### Removed
Expand Down
1 change: 1 addition & 0 deletions dry-operation.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ Gem::Specification.new do |spec|

spec.required_ruby_version = ">= 3.3"

spec.add_runtime_dependency "dry-core", "~> 1.1"
spec.add_runtime_dependency "dry-monads", "~> 1.6"
spec.add_runtime_dependency "zeitwerk", "~> 2.6"
spec.add_development_dependency "bundler"
Expand Down
123 changes: 96 additions & 27 deletions lib/dry/operation.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# frozen_string_literal: true

require "zeitwerk"
require "dry/core/constants"
require "dry/monads"
require "dry/operation/errors"

Expand Down Expand Up @@ -74,45 +75,55 @@ module Dry
# As you can see, the aforementioned behavior allows you to write your flow
# in a linear fashion. Failures are mostly handled locally by each individual
# operation. However, you can also define a global failure handler by defining
# an `#on_failure` method. It will be called with the wrapped failure value
# and, in the case of accepting a second argument, the name of the method that
# defined the flow:
# an `#on_failure` method. It will always be called with the wrapped failure
# value as its first argument, and may optionally accept either a positional
# second argument (the prepended method name) or `step_name:` and/or
# `method_name:` keyword arguments:
#
# ```ruby
# class MyOperation < Dry::Operation
# def call(input)
# attrs = step validate(input)
# user = step persist(attrs)
# step notify(user)
# attrs = step :validate, validate(input)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think keeping the value as the first positional parameter would read better?

I’m thinking about something like:

step validate(input), as: :validate

Not a strong opinion, but it could also make refactoring easier when we want to provide a name in a second thought, since appending something to a line is always easier than inserting something in the middle for most IDEs 🙂

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This reads more naturally. Is it possible to use reflection to infer the method's name and omit the symbol altogether, making it optional?

step validate(input)           # on_failure gets :validate as step_name
step persist(input), as: :save # on_failure gets :save as step_name

# user = step :persist, persist(attrs)
# step :notify, notify(user)
# user
# end
#
# def on_failure(user) # or def on_failure(failure_value, method_name)
# log_failure(user)
# def on_failure(failure_value, step_name:)
# case step_name
# when :validate then log_validation_failure(failure_value)
# when :persist then log_persistence_failure(failure_value)
# when :notify then log_notification_failure(failure_value)
# end
# end
# end
# ```
#
# Naming steps is optional. When {#step} is called with just a result,
# `step_name:` will be `nil`. The `method_name:` kwarg always reflects the
# prepended method name (`:call` by default).
#
# 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 and manually
# handle global failures.
# you manually need to wrap your flow within the {#steps} method.
# `#on_failure` is still dispatched by `#steps` itself, so it works the same
# as in the prepended case:
#
# ```ruby
# class MyOperation < Dry::Operation
# skip_prepending
#
# def call(input)
# steps do
# attrs = step validate(input)
# user = step persist(attrs)
# step notify(user)
# attrs = step :validate, validate(input)
# user = step :persist, persist(attrs)
# step :notify, notify(user)
# user
# end.tap do |result|
# log_failure(result.failure) if result.failure?
# end
# end
#
# # ...
# def on_failure(failure, step_name:)
# log_failure(failure, step_name)
# end
# end
# ```
#
Expand All @@ -138,21 +149,54 @@ def self.loader
end
loader.setup

# @api private
FAILURE_TAG = :halt
private_constant :FAILURE_TAG

# @api private
Undefined = Dry::Core::Constants::Undefined

# Internal throw payload pairing a failure with the name of the step that
# produced it. Used so the step name can flow back to `#on_failure`.
#
# @api private
StepFailure = Data.define(:failure, :step_name)

extend ClassContext
include Dry::Monads::Result::Mixin

# Wraps block's return value in a {Dry::Monads::Result::Success}
#
# Catches `:halt` and returns it
# Catches `:halt`, unwraps any step name carried by the throw, and
# dispatches the failure (if any) to `#on_failure`.
#
# The prepender passes its `__method__` as `method_name:` so it flows to
# `#on_failure`'s `method_name:` kwarg. Manual callers (e.g. with
# {ClassContext#skip_prepending}) can pass it themselves; otherwise the
# `method_name:` kwarg arrives as `nil`.
#
# @param method_name [Symbol, nil] surfaced to `#on_failure` via `method_name:` on failure
# @yieldreturn [Object]
# @return [Dry::Monads::Result::Success]
# @return [Dry::Monads::Result::Success, Object] the wrapped block result, or
# the unwrapped failure / direct-throw value
# @see #step
def steps(&block)
catching_failure { Success(block.call) }
def steps(method_name: nil, &block)
output = catch(FAILURE_TAG) { Success(block.call) }

result, step_name =
if output.is_a?(StepFailure)
[output.failure, output.step_name]
else
[output, nil]
end

ClassContext::FailureHookDispatcher.call(
self,
method_name: method_name,
step_name: step_name,
result: result
)

result
end

# Unwraps a {Dry::Monads::Result::Success}
Expand All @@ -161,14 +205,37 @@ def steps(&block)
#
# If the given result responds to `#to_result`, this will be called before processing.
#
# @param result [Dry::Monads::Result, #to_result]
# Optionally accepts a step name as the first argument. When given, the name is
# forwarded to `#on_failure` via the `step_name:` kwarg if the step fails.
#
# ```ruby
# def call(input)
# attrs = step :validate, validate(input)
# user = step :persist, persist(attrs)
# step :notify, notify(user)
# user
# end
# ```
#
# @overload step(result)
# @param result [Dry::Monads::Result, #to_result]
# @overload step(name, result)
# @param name [Symbol] identifier surfaced to `#on_failure` via `step_name:` on failure
# @param result [Dry::Monads::Result, #to_result]
# @return [Object] wrapped value
# @see #steps
def step(result)
def step(name_or_result, result = Undefined)
if Undefined.equal?(result)
step_name = nil
result = name_or_result
else
step_name = name_or_result
end

raise InvalidStepResultError.new(result: result) unless result.respond_to?(:to_result)

result = result.to_result
result.value_or { throw_failure(result) }
result.value_or { throw_failure(result, step_name:) }
end

# Invokes a callable in case of block's failure
Expand Down Expand Up @@ -196,14 +263,16 @@ def intercepting_failure(handler = method(:throw_failure), &block)
# Throws `:halt` with a failure
#
# @param failure [Dry::Monads::Result::Failure]
def throw_failure(failure)
throw FAILURE_TAG, failure
# @param step_name [Symbol, nil] surfaced to `#on_failure` if set
def throw_failure(failure, step_name: nil)
throw FAILURE_TAG, StepFailure.new(failure, step_name)
end

private

def catching_failure(&block)
catch(FAILURE_TAG, &block)
output = catch(FAILURE_TAG, &block)
output.is_a?(StepFailure) ? output.failure : output
end
end
end
88 changes: 88 additions & 0 deletions lib/dry/operation/class_context/failure_hook_dispatcher.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# frozen_string_literal: true

require "dry/operation/errors"

module Dry
class Operation
module ClassContext
# Dispatches a failure result to an operation instance's `#on_failure`
# hook, supporting several signatures:
#
# def on_failure(failure)
# def on_failure(failure, method_name)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we could find a more descriptive name instead of method_name. I thought about operation_name but it'd also be confusing given we define the class as a Dry::Operation 🤔

# def on_failure(failure, step_name:)
# def on_failure(failure, method_name:)
# def on_failure(failure, step_name:, method_name:)
#
# @api private
module FailureHookDispatcher
FAILURE_HOOK_METHOD_NAME = :on_failure

SUPPORTED_KWARGS = %i[step_name method_name].freeze
private_constant :SUPPORTED_KWARGS

class << self
def call(instance, method_name:, step_name:, result:)
return unless result.is_a?(Dry::Monads::Result::Failure)

hook = lookup_hook(instance)
return unless hook

invoke_hook(hook, failure: result.failure, step_name: step_name, method_name: method_name)
end

private

def lookup_hook(instance)
return unless (instance.methods + instance.private_methods).include?(FAILURE_HOOK_METHOD_NAME)

instance.method(FAILURE_HOOK_METHOD_NAME)
end

# Dispatches to the hook based on its positional params arity.
#
# - Arity of 1: modern form. The hook receives `failure`, plus whichever of `step_name:`
# and `method_name:` is explicitly accepted. When it accepts neither, the slice is
# empty and `**{}` makes this equivalent to a plain `hook.(failure)` call.
# - Arity of 2: legacy form. The second positional is always `method_name`. Reject any
# kwargs here, because mixing the two styles would be ambiguous about which identifier
# the second positional carries.
# - Any other arity is unsupported and rejected.
def invoke_hook(hook, failure:, step_name:, method_name:)
positional, accepted_kwargs = parse_signature(hook)

case positional
when 1
kwargs = {step_name:, method_name:}.slice(*accepted_kwargs)
hook.(failure, **kwargs)
when 2
raise FailureHookArityError.new(hook: hook) unless accepted_kwargs.empty?

hook.(failure, method_name)
else
raise FailureHookArityError.new(hook: hook)
end
end

# Returns [positional_count, kwargs_array] from the failure hook method's `parameters`.
def parse_signature(hook)
positional = 0
kwargs = []

hook.parameters.each do |type, name|
case type
when :req, :opt then positional += 1
when :key, :keyreq then kwargs << name
else raise FailureHookArityError.new(hook: hook)
end
end

raise FailureHookArityError.new(hook: hook) if (kwargs - SUPPORTED_KWARGS).any?

[positional, kwargs]
end
end
end
end
end
end
34 changes: 4 additions & 30 deletions lib/dry/operation/class_context/steps_method_prepender.rb
Original file line number Diff line number Diff line change
@@ -1,35 +1,13 @@
# frozen_string_literal: true

require "dry/operation/errors"

module Dry
class Operation
module ClassContext
# @api private
class StepsMethodPrepender < Module
FAILURE_HOOK_METHOD_NAME = :on_failure

RESULT_HANDLER = lambda do |instance, method, result|
return if result.success? ||
!(instance.methods + instance.private_methods).include?(
FAILURE_HOOK_METHOD_NAME
)

failure_hook = instance.method(FAILURE_HOOK_METHOD_NAME)
case failure_hook.arity
when 1
failure_hook.(result.failure)
when 2
failure_hook.(result.failure, method)
else
raise FailureHookArityError.new(hook: failure_hook)
end
end

def initialize(method:, result_handler: RESULT_HANDLER)
def initialize(method:)
super()
@method = method
@result_handler = result_handler
end

def included(klass)
Expand All @@ -40,13 +18,9 @@ def included(klass)

def mod
@module ||= Module.new.tap do |mod|
module_exec(@result_handler) do |result_handler|
mod.define_method(@method) do |*args, **kwargs, &block|
steps do
super(*args, **kwargs, &block)
end.tap do |result|
result_handler.(self, __method__, result)
end
mod.define_method(@method) do |*args, **kwargs, &block|
steps(method_name: __method__) do
super(*args, **kwargs, &block)
end
end
end
Expand Down
11 changes: 8 additions & 3 deletions lib/dry/operation/errors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,17 @@ def initialize(result:)
# An error related to an extension
class ExtensionError < ::StandardError; end

# Defined failure hook has wrong arity
# Defined failure hook has an unsupported signature
class FailureHookArityError < ::StandardError
def initialize(hook:)
super <<~MSG
##{hook.name} must accept 1 (failure) or 2 (failure, method name) \
arguments, but its arity is #{hook.arity}
##{hook.name} must accept one of the following signatures:
(failure)
(failure, method_name)
(failure, step_name:)
(failure, method_name:)
(failure, step_name:, method_name:)
Its arity is #{hook.arity}.
MSG
end
end
Expand Down
Loading