-
-
Save sirsean/c53fc4d934e085988841 to your computer and use it in GitHub Desktop.
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 |
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.
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.
Is there a reason we're rescuing all
StandardError
s? That includes a lot of errors that I would consider to be programmer errors, likeNoMethodError
andArgumentError
. 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?