Ruby metaprogramming: Not scary at all

There seems to be a sort of mystique around the concept of metaprogramming, but in Ruby it’s really not mystical at all. It’s all about leveraging a few existing methods in smart ways to let you eliminate boring code. This sort of thing is possible in my “native language” of Objective-C, but in Ruby it turns out to be even easier.

Today I’m going to talk about how to use the method_missing method to eliminate repetitive boilerplate code. method_missing is Ruby’s fallback strategy for unresolvable method names encountered at runtime. If you try to call a non-existent method on any object, the Ruby runtime will instead call method_missing on that object; The default behavior of this method is to raise an exception, but we can override it to do smart things based on the method name.

The Badness

The Rails application I’m working on has a role-based permissions scheme, with a ManagementRole model that sits between User and Program models, allowing us to define roles that a User can have relative to a given Program, which in turn constrain what they’re allowed to do with the application. These roles are expressed concretely as simple subclasses of ManagementRole, with names like RoleSearchMembers and RoleCreateMemberships. Up until recently, checking whether a User were allowed to perform certain tasks within a Program consisted of calling one of several nearly-identical class methods defined on ManagementRole, such as these:


  def self.allow_search_members?(user, program)
    return User.is_administrator?(user) ||
      self.program_role_exists?(user, program, "RoleSearchMembers")
  end
  
  def self.allow_create_memberships?(user, program)
    return User.is_administrator?(user) ||
      self.program_role_exists?(user, program, "RoleCreateMemberships")
  end

  # etc
  
  # the program_role_exists? method, not listed here, simply checks
  # for the existence of a matching ManagementRole object

As you can see, this is extremely repetitive, and adding new types of permissions means adding new, nearly-identical methods. As of now there are 18 methods like this, and that number will just go up in the future, leading to even more boilerplate code. Where will this madness end?

The Goodness

Providing a simple definition for method_missing in the same class lets me eliminate all those 18 methods, and will automatically deal with any new ManagementRole subclasses. It looks like this:


  # instead of a bunch of allow_xxx_xxx_xxx? methods that differ only slightly, 
  # we just catch all such calls here.
  def self.method_missing(method_id, *args)
    if match = /allow_([_a-zA-Z]\w*)\?/.match(method_id.to_s) and args.size==2
      return User.is_administrator?(args[0]) || 
        self.program_role_exists?(args[0], args[1], "Role#{match[1].camelize}")
    else
      super
    end
  end

The first thing this does is examine the method and the argument list, to determine if it looks like the kind of method we are trying to replace. If the method name looks anything like “allow_xxx_yyy_zzz?”, and if there are exactly 2 arguments, then we’ll do something interesting. Otherwise, we’ll just call super and let the parent class deal with it.

In the “interesting” case, we simply perform the same checks we did before to determine whether we return true or false, but now we’re using indexed values from the args array, and constructing the string naming the MangementRole subclass by using the result of the earlier regexp match. Voila!

The Even-Betterness

Of course, software development being what it is, any time you revisit an old design to try to improve it, the act of improving it can lead you to discover new, further improvements. While writing this, I realized something that would have avoided all this boilerplate code, and the metaprogramming it led to, in the first place: Instead of asking the ManagementRole class explicitly about each of these permissions, I’ll be better off writing a single method called allow? in the ManagementRole class, which will do a lookup based on the recipient’s class-name, e.g. RoleSearchMembers, RoleCreateMemberships. And then instead of calling ManagementRole.allow_search_members?(u,p) , I will call RoleSearchMembers.allow?(u,p). That will lead to even less code than the metaprogramming version, and less code is always better code. I haven’t written it yet, but it’ll probably look something like this:


  def self.allow?(user, program)
    return User.is_administrator?(user) ||
      self.find_by_user_and_program(user, program)
  end

So this problem, with its metaprogramming-to-the-rescue solution, turned out to be easily solvable with more traditional object-orientation, but that’s OK! Hopefully this post will be helpful anyway, for someone else whose problem doesn’t have the same easy solution that mine turned out to have…

Comments