Saturday, May 19, 2007

Ruby: method_missing alternatives

I love the value that method_missing provides; unfortunately, sometimes I get into trouble when I abuse the power Matz has bestowed upon me. If you've done much work with method_missing the following error message probably isn't foreign to you.
SystemStackError: stack level too deep
It's a painful message to see, as it's always problematic to debug. Given the inherent difficultly of debugging method_missing calls I thought it might be valuable to offer a few alternatives.

As an example I'll use the valid_for_[a group] methods that Validatable exposes. The Validatable validations allow you to put your validations into groups.
class User
include Validatable

attr_accessor :first_name, :last_name, :ssn
validates_presence_of :first_name, :last_name, :ssn, :groups => :account_creation
validates_presence_of :ssn, :groups => :persistence
end
Given the above User class you can expect the following behavior.
irb(main):010:0> user = User.new
=> #<User:0x696900>
irb(main):011:0> user.ssn = 111223333
=> 111223333
irb(main):012:0> user.valid_for_persistence?
=> true
irb(main):013:0> user.valid_for_account_creation?
=> false
irb(main):014:0> user.first_name = 'Shane'
=> "Shane"
irb(main):015:0> user.last_name = 'Harvie'
=> "Harvie"
irb(main):016:0> user.valid_for_persistence?
=> true
irb(main):017:0> user.valid_for_account_creation?
=> true
As you can see the User instance exposes the valid_for_account_creation? and the valid_for_persistence? methods. Of course, we want the valid_for_[a group] methods to change based on the groups defined in each class. Additionally, we only want valid_for_something? to be defined on a class if that class contains any validations in the something group. This looks like a classic situation for using method_missing; however, I chose one of the following other solutions.

Delegate Responsibility:
The basic idea behind this solution alternative is to move the method_missing logic into a class whose sole responsibility is the method_missing logic. For example, had I chosen this solution for Validatable I would have made the valid methods work like the following example.
irb(main):010:0> user = User.new
=> #<User:0x696900>
irb(main):011:0> user.ssn = 111223333
=> 111223333
irb(main):012:0> user.valid_for.persistence?
=> true
irb(main):013:0> user.valid_for.account_creation?
=> false
Given the above syntax I could have created a ValidFor class that encapsulated the logic for dynamically executing a subset of validations. A few cons for this solution are the obvious violation of the Law of Demeter and the fact that the stack overflow can still exist in the delegate class. However, I like to use this solution when the methods that are necessary are truly dynamic and cannot be easily predicted (i.e. the find_by_* methods that ActiveRecord::Base exposes). Additionally, the added class removes variables when trying to figure out where the logic went wrong.

Metaprogramming:
I chose this solution because I was able to easily identify which methods were necessary and define then at interpretation time. In fact the implementation for defining the valid methods is quite simple.
def create_valid_method_for_groups(groups) #:nodoc:
groups.each do |group|
self.class_eval do
define_method "valid_for_#{group}?".to_sym do
valid_for_group?(group)
end
end
end
end
Given the above code a class will define a new valid_for_[a group] method for each group given to any of it's validations. By explicitly defining each method I get much more manageable error messages when things do go wrong.

2 comments:

  1. Anonymous5:47 PM

    A common Perl trick is to install the method into the class at AUTOLOAD time and then dispatch to that method. The theory being that if you use the dynamically generated method once you're going to use it again.

    I still find myself occasionally wondering ActiveRecord's dynamic finders don't do the same thing.

    ReplyDelete
  2. Your last example has an error. You meant to say "self.class.class_eval", right?

    I think your example was supposed to read like this: http://pastie.org/444986

    ReplyDelete

Note: Only a member of this blog may post a comment.