Skip to content

Commit

Permalink
Switch to concrete Success/Failure result classes
Browse files Browse the repository at this point in the history
Within an operation data is now gathered on a separate Data object that’s passed to the result on completion.
  • Loading branch information
benpickles committed May 11, 2024
1 parent 4f036f9 commit 67c4605
Show file tree
Hide file tree
Showing 8 changed files with 402 additions and 219 deletions.
35 changes: 20 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ class SayHello
attr_reader :name

# Declare convenience accessors on the result.
result_attr :message
data_attr :message

def call
# Exit the method and mark the result as a failure.
Expand All @@ -36,30 +36,22 @@ class SayHello
end

result = SayHello.call(name: 'Dave')
result.class # => Operatic::Success
result.failure? # => false
result.success? # => true
result.message # => "Hello Dave"
result[:message] # => "Hello Dave"
result.to_h # => {:message=>"Hello Dave"}

result = SayHello.call
result.class # => Operatic::Failure
result.failure? # => true
result.success? # => false
result.message # => nil
result[:message] # => nil
result.to_h # => {}
```

An `Operatic::Result` also supports pattern matching in Ruby 2.7+ returning an array of `[success, data]`:

```ruby
case SayHello.call(name: 'Dave')
in [true, { message: }]
# Result is a success, do something with the `message` variable.
in [false, _]
# Result is a failure, do something else.
end
```

A Rails controller might use Operatic like this:

```ruby
Expand All @@ -76,15 +68,28 @@ class HellosController < ApplicationController
end
```

Or a pattern matching alternative:
## Pattern matching

An Operatic result also supports pattern matching in Ruby 2.7+ returning an array of `[success, data]`:

```ruby
case SayHello.call(name: 'Dave')
in [Operatic::Success, { message: }]
# Result is a success, do something with the `message` variable.
in [Operatic::Failure, _]
# Result is a failure, do something else.
end
```

Which might be consumed in Rails like this:

```ruby
class HellosController < ApplicationController
def create
case SayHello.call(name: params[:name])
in [true, { message: }]
in [Operatic::Success, { message: }]
render plain: message
in [false, _]
in [Operatic::Failure, _]
render :new
end
end
Expand Down
144 changes: 91 additions & 53 deletions lib/operatic.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
require 'operatic/data'
require 'operatic/errors'
require 'operatic/result'
require 'operatic/version'
Expand All @@ -9,115 +10,152 @@ def self.included(base)
end

module ClassMethods
# The main way of calling an operation.
# The main way to call an operation. This initializes the class with the
# supplied +attrs+ keyword arguments and calls {Operatic#call} returning a
# frozen {Result} instance.
#
# The class is instantiated with the supplied +attrs+ keyword arguments and
# calls {Operatic#call} returning a frozen {Result} instance.
# @param attrs [Hash<Symbol, anything>]
#
# @return [Result]
# @return [Failure, Success]
def call(**attrs)
new(**attrs)
.tap(&:call)
.result
.freeze
operation = new(**attrs)
operation.call
operation.result || Success.new(operation.data).freeze
end

# Calls {#call} but raises {FailureError} if the returned {Result} is a
# {Result#failure?} - useful for things like background jobs, rake tasks,
# test setups, etc.
# The same as {#call} but raises {FailureError} if the returned {#result} is
# a {Failure} - useful for things like background jobs, rake tasks, test
# setups, etc.
#
# @return [Result]
# @param attrs [Hash<Symbol, anything>]
#
# @return [Success]
#
# @raise [FailureError] if the operation is not a {Success}
def call!(**attrs)
call(**attrs).tap { |result|
raise FailureError if result.failure?
}
end

# Define a {Result} subclass with named accessors specific to the class via
# {Result.generate}.
# Define a class-specific {Data} subclass with the named accessors added via
# {Data.define}.
#
# @example
# class SayHello
# include Operatic
#
# attr_reader :name
#
# result_attr :message
# data_attr :message
#
# def call
# success!(message: "Hello #{name}")
# success!(message: "Hello #{@name}")
# end
# end
#
# result = SayHello.call(name: 'Dave')
# result.success? # => true
# result.message # => "Hello Dave"
# result.class # => Operatic::Success
# result.message # => "Hello Dave"
# result[:message] # => "Hello Dave"
# result.to_h # => {:message=>"Hello Dave"}
#
# @param attrs [Array<Symbol>] a list of convenience data accessors to
# define on the {Result}.
def result_attr(*attrs)
@result_class = Result.generate(*attrs)
def data_attr(*attrs)
@data_class = Data.define(*attrs)
end

def result_class
@result_class || Result
# @return [Class<Data>]
def data_class
@data_class || Data
end
end

# @return [Success, Failure]
attr_reader :result

# @param attrs [Hash<Symbol, anything>]
def initialize(**attrs)
attrs.each do |key, value|
instance_variable_set("@#{key}", value)
end
end

# Override this method with your implementation. Use {#success!} or
# {#failure!} methods to communicate the {#result}'s status and to attach
# data to it. Define convenience result accessors with
# {ClassMethods#result_attr}.
# Override this method with your implementation. Use {#success!}/{#failure!}
# to define the status of the result {Success}/{Failure} and attach data.
#
# @example
# class SayHello
# include Operatic
#
# attr_reader :name
#
# result_attr :message
#
# def call
# return failure! unless name
# success!(message: "Hello #{name}")
# return failure! unless @name
# success!(message: "Hello #{@name}")
# end
# end
#
# result = SayHello.call(name: 'Dave')
# result.failure? # => false
# result.success? # => true
# result.message # => "Hello Dave"
# result.to_h # => {:message=>"Hello Dave"}
# result.class # => Operatic::Success
# result.failure? # => false
# result.success? # => true
# result[:message] # => "Hello Dave"
# result.to_h # => {:message=>"Hello Dave"}
#
# result = SayHello.call
# result.failure? # => true
# result.success? # => false
# result.message # => nil
# result.to_h # => {}
# result.class # => Operatic::Failure
# result.failure? # => true
# result.success? # => false
# result.to_h # => {}
def call
end

# Convenience shortcut to the operation's {Result#failure!}.
def failure!(**data)
result.failure!(**data)
# Any data to be communicated via the operation's result should be added to
# this {Data} object.
#
# *Note*: This will be frozen when returned from an operation.
#
# @example
# class SayHello
# include Operatic
#
# def call
# data[:message] = "Hello #{@name}"
# end
# end
#
# result = SayHello.call(name: 'Dave')
# result.data.to_h # => {:message=>"Dave"}
# result.data.frozen? # => true
#
# @return [Data]
def data
@data ||= self.class.data_class.new
end

# An instance of {Result} or a subclass generated by
# {ClassMethods#result_attr}.
# Mark the operation as a failure and prevent further modification to the
# operation, its result, and its data.
#
# @return [Result]
def result
@result ||= self.class.result_class.new
# @param kwargs [Hash<Symbol, anything>]
#
# @raise [FrozenError] if called more than once
def failure!(**kwargs)
@result = Failure.new(data.merge(kwargs))
freeze
end

# @return [self]
def freeze
@result.freeze
super
end

# Convenience shortcut to the operation's {Result#success!}.
def success!(**data)
result.success!(**data)
# Mark the operation as a success and prevent further modification to the
# operation, its result, and its data.
#
# @param kwargs [Hash<Symbol, anything>]
#
# @raise [FrozenError] if called more than once
def success!(**kwargs)
@result = Success.new(data.merge(kwargs))
freeze
end
end
72 changes: 72 additions & 0 deletions lib/operatic/data.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
module Operatic
class Data
# Generate a subclass of {Data} with named +attrs+ accessors. This wouldn't
# normally be called directly, see {ClassMethods#data_attr} for example
# usage.
#
# @param attrs [Array<Symbol>] a list of convenience data accessors.
def self.define(*attrs)
Class.new(self) do
attrs.each do |name|
define_method name do
self[name]
end

define_method "#{name}=" do |value|
self[name] = value
end
end
end
end

# @param kwargs [Hash<Symbol, anything>]
def initialize(**kwargs)
@data = kwargs
end

# Return the value for +key+.
#
# @param key [Symbol]
#
# @return [anything]
def [](key)
@data[key]
end

# Set data on the result.
#
# @param key [Symbol]
# @param value [anything]
def []=(key, value)
@data[key] = value
end

# @return [self]
def freeze
@data.freeze
super
end

# @param hash [Hash<Symbol, anything>]
#
# @return [Data]
def merge(hash)
self.class.new.tap { |other|
other.set_data(@data)
other.set_data(hash)
}
end

# @return [Hash<Symbol, anything>]
def to_h
@data
end

protected
def set_data(data)
data.each do |key, value|
@data[key] = value
end
end
end
end
Loading

0 comments on commit 67c4605

Please sign in to comment.