Last active
August 29, 2015 14:04
-
-
Save sirsean/c53fc4d934e085988841 to your computer and use it in GitHub Desktop.
Command pattern with required/optional fields, support for type coercion, and validation.
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
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 |
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
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.