Skip to content

Instantly share code, notes, and snippets.

@sirsean
Last active August 29, 2015 14:04
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save sirsean/c53fc4d934e085988841 to your computer and use it in GitHub Desktop.
Save sirsean/c53fc4d934e085988841 to your computer and use it in GitHub Desktop.
Command pattern with required/optional fields, support for type coercion, and validation.
module Command
extend ActiveSupport::Concern
include ActiveModel::Validations
module ClassMethods
def required(key, as: nil)
add_field(key, as)
validates key, presence: true
end
private :required
def optional(key, as: nil, default: nil)
add_field(key, as, default)
end
private :optional
def add_field(key, as=nil, default=nil)
define_method(key) do
@fields[key] || Coercer.coerce(default, as)
end
private key
define_method("#{key}=") do |value|
@fields[key] = Coercer.coerce(value, as)
end
private "#{key}="
end
private :add_field
def command(&block)
@command_block = block
end
private :command
attr_reader :command_block
end
def initialize(opts={})
@fields = {}
opts.each do |key, value|
send("#{key}=", value)
end
end
def execute
if valid?
begin
result = instance_eval(&self.class.command_block)
rescue => e
add_error(:runtime, e.message)
end
end
Outcome.new(result: result, errors: errors)
end
def add_error(key, message)
errors[key] << message
end
private :add_error
end
class Coercer
class << self
def coerce(value, type)
if coercer = @coercers[type]
coercer.new.coerce(value)
else
value
end
end
def add_type(type, coercer)
coercers[type] = coercer
end
def coercers
@coercers ||= {}
end
end
end
class IntegerCoercer
def coerce(value)
value.to_i
end
end
Coercer.add_type(:integer, IntegerCoercer)
class BooleanCoercer
FALSY = ["false", "0", 0]
def coerce(value)
FALSY.each do |falsy|
value = false if value == falsy
end
!!value
end
end
Coercer.add_type(:boolean, BooleanCoercer)
class Outcome
def initialize(result: nil, errors: {})
@result = result
@errors = errors
end
attr_reader :result, :errors
def success?
errors.empty?
end
end
class King
def initialize(opts)
opts.each do |k,v|
instance_variable_set("@#{k}", v)
end
end
end
class KingMaker
include Command
required :name
optional :age, as: :integer, default: 100
optional :replacement
optional :abdicated, as: :boolean, default: false
optional :extra
validates :abdicated, presence: true, if: ->{ replacement }
command do
#raise "something failed!"
#add_error(:other, "things")
King.new(name: name, age: age)
end
end
maker = KingMaker.new(name: "henry", age: 8, replacement: "some guy", abdicated: false)
pp maker
outcome = maker.execute
pp outcome
@jdfrens
Copy link

jdfrens commented Jul 25, 2014

Is there a reason we're rescuing all StandardErrors? That includes a lot of errors that I would consider to be programmer errors, like NoMethodError and ArgumentError. The app should crash and burn (and notify Airbrake) if we hit these. They also come up as part of normal development, and swallowing the stack trace is a real PITA.

I'd be somewhat okay rescuing RuntimeError, but I guess I don't see the need for that either. What problem does that solve? Could we solve that problem with hooks of some sort, like ActiveSupport::Rescuable?

@cstump
Copy link

cstump commented Jul 25, 2014

Nice syntactic sugar. However, I wonder about #command. The block passed to #command is basically a method body, so why not require the including class to implemented a specific method instead? Alternatively, you can pass a method name to #command. Either approach is familiar to ActiveRecord callback users, and a nice side effect is that you won't have to use #instance_eval in #execute.

I'm afraid the current impl will lead to some unwieldy blocks being passed to #command. Everyone knows to keep methods short and succinct, but if you're not using the def mymethod syntax folks may not be inclined to follow the same principle. On the other hand, I could see this happening a lot: command { self.mymethod } in which case you might as well pass a method name or skip #command all together by having #execute call a required method.

@sirsean
Copy link
Author

sirsean commented Jul 28, 2014

My problem with either having a required method or allowing the user to specify a method to call is that I want to discourage anyone from calling anything other than #execute.

For example:

class KingMaker
  include Command

  def _execute
    #do stuff
  end
end

KingMaker.new._execute # no!

or

class KingMaker
  include Command

  command :my_logic
  def my_logic
    # do stuff
  end
end

KingMaker.new.my_logic # no!

There's nothing stopping you from breaking your command out into good Ruby code.

class KingMaker
  include Command

  command do
    if complicated_thing?
      do_something_good
    else
      do_something_bad
    end
  end

  private

  def complicated_thing?
  end

  def do_something_good
  end

  def do_something_bad
  end
end

But it does prevent you from doing what we don't want you do, like calling the command block without calling #execute which would prevent you from getting an Outcome object.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment