Reading Rails - Attribute Methods

In our last exploration, we saw that rails used attribute methods in change tracking. There are three types of attribute methods: prefix, suffix, and affix. For clarity, we will focus on attribute_method_suffix, and specifically how it allows us to take a model attribute like name and generate methods like name_changed?.

To follow along, open each library in your editor with qwandry, or just look it up on Github.

Declarations

Attribute methods are one of many examples of metaprogramming in Rails. When metaprogramming, we write code that that writes code. For instance attribute_method_suffix is a method that defines a set of helper methods for each attribute. As we saw previously, ActiveModel uses this to define a _changed? method for each of your attributes:

module Dirty
  extend ActiveSupport::Concern
  include ActiveModel::AttributeMethods

  included do
    attribute_method_suffix '_changed?', '_change', '_will_change!', '_was'
    #...

Let's jump on into ActiveModel's attribute_methods.rb, and see what's going on.

def attribute_method_suffix(*suffixes)
  self.attribute_method_matchers += suffixes.map! do |suffix| 
    AttributeMethodMatcher.new suffix: suffix 
  end
  #...
end

When you call attribute_method_suffix, each of the suffixes are converted into an AttributeMethodMatcher using map!. These objects are stored in attribute_method_matchers. If you look at the top of the module, you'll see attribute_method_matchers is a class_attribute defined on any class including this module:

module AttributeMethods
  extend ActiveSupport::Concern

  included do
    class_attribute :attribute_aliases, 
                    :attribute_method_matchers, 
                    instance_writer: false
    #...

A class_attribute lets you define attributes on a class. You can use them in your own code:

class Person
  class_attribute :database
  #...
end

class Employee < Person
end

Person.database = Sql.new(:host=>'localhost')
Employee.database #=> <Sql:host='localhost'>

Ruby doesn't have a built in notion of a class_attribute, this is defined by ActiveSupport. If you're curious, peek at attribute.rb.

Now we will take a look at AttributeMethodMatcher.

class AttributeMethodMatcher #:nodoc:
  attr_reader :prefix, :suffix, :method_missing_target

  def initialize(options = {})
    #...
    @prefix, @suffix = options.fetch(:prefix, ''), options.fetch(:suffix, '')
    @regex = /^(?:#{Regexp.escape(@prefix)})(.*)(?:#{Regexp.escape(@suffix)})$/
    @method_missing_target = "#{@prefix}attribute#{@suffix}"
    @method_name = "#{prefix}%s#{suffix}"
  end

The prefix and suffix options are extracted using Hash#fetch. This returns either the value for the key, or a default value. If a default value is not supplied, Hash#fetch will raise exception if the key does not exist. This is a good pattern for handling options, especially booleans:

options = {:name => "Mortimer", :imaginary => false}
# Don't do this:
options[:imaginary] || true     #=> true
# Do this:
options.fetch(:imaginary, true) #=> false

For our example of attribute_method_suffix '_changed?', the AttributeMethodMatcher will have the following instance variables:

@prefix                #=> ""
@suffix                #=> "_changed?"
@regex                 #=> /^(?:)(.*)(?:_changed\?)$/
@method_missing_target #=> "attribute_changed?"
@method_name           #=> "%s_changed?"

You may wonder what the %s is for in %s_changed?, this a format string. You can interpolate values into it using sprintf, or % as a shortcut:

sprintf("%s_changed?", "name") #=> "named_changed?"
"%s_changed?" % "age"          #=> "age_changed?"

The second interesting bit is how the regular expression is built. Notice the usage of Regexp.escape when building @regex. If the suffix were not escaped, then characters with special meaning in regular expressions would be misinterpreted:

# Don't do this!
regex = /^(?:#{@prefix})(.*)(?:#{@suffix})$/ #=> /^(?:)(.*)(?:_changed?)$/
regex.match("name_changed?")                 #=> nil
regex.match("name_change")                   #=> #<MatchData "name_change" 1:"name">

# Do this:
@regex = /^(?:#{Regexp.escape(@prefix)})(.*)(?:#{Regexp.escape(@suffix)})$/
regex.match("name_changed?")                 #=> #<MatchData "name_changed?" 1:"name">
regex.match("name_change")                   #=> nil

Keep regex and method_name in mind, they can be used to match and generate attribute method names, and we will see them again later.

Now that we have figured out how attribute methods are declared, but how does Rails actually use them?

Invocation With Method Missing

Whenever an undefined method is called, Ruby will call method_missing on the object before throwing an exception. Let's see how Rails uses this to invoke attribute methods:

def method_missing(method, *args, &block)
  if respond_to_without_attributes?(method, true)
    super
  else
    match = match_attribute_method?(method.to_s)
    match ? attribute_missing(match, *args, &block) : super
  end
end

The first argument to method_missing is the method name as a symbol, :name_changed? for example. The *args are the arguments to the method call, and &block is an optional block. Rails first checks to see if anything else could respond to this call by calling respond_to_without_attributes. If some other method can handle this call, it will pass on control via super. If nothing else can handle this, then ActiveModel checks to see if the call looks like an attribute method using match_attribute_method?, and if that matches, it will call attribute_missing.

The match_attribute_method makes use of the AttributeMethodMatcher declared above:

def match_attribute_method?(method_name)
  match = self.class.send(:attribute_method_matcher, method_name)
  match if match && attribute_method?(match.attr_name)
end

Two things happen in this method. First, a matcher is found, then Rails checks to see if this is actually an attribute. To be honest, I'm puzzled as to why match_attribute_method? calls self.class.send(:attribute_method_matcher, method_name) instead of just calling self.attribute_method_matcher(method_name), but we can assume it has the same effect.

If we look at attribute_method_matcher, we'll see that the heart of it is just scanning over the AttributeMethodMatcher instances using match, which compares its regular expression with this method name:

def attribute_method_matcher(method_name)
  #...
  attribute_method_matchers.detect { |method| method.match(method_name) }
  #...
end

If Rails found a match for the method we called, then all the arguments will be passed on to attribute_missing:

def attribute_missing(match, *args, &block)
  __send__(match.target, match.attr_name, *args, &block)
end

This method delegates to match.target with the matched attribute's name and any arguments or block passed in. Refer back to our instance variables. match.target would be "attribute_changed?", and match.attr_name would be "name". The __send__ will call attribute_changed?, or whatever special attribute method you defined.

Metaprogramming

That's a lot of work just to dispatch a single method call, if this will be called often, then it would be more efficient to just implement name_changed?. Rails achieves this by defining those methods automatically with define_attribute_methods:

def define_attribute_methods(*attr_names)
  attr_names.flatten.each { |attr_name| define_attribute_method(attr_name) }
end

def define_attribute_method(attr_name)
  attribute_method_matchers.each do |matcher|
    method_name = matcher.method_name(attr_name)

    define_proxy_call true, 
                      generated_attribute_methods, 
                      method_name, 
                      matcher.method_missing_target, 
                      attr_name.to_s
  end
end

matcher.method_name uses the format string we saw above, and interpolates in attr_name. For our example, "%s_changed?" becomes "name_changed?". Now we're ready for some metaprogramming with define_proxy_call. Below is a version of the method with some of the special cases removed, as always poke around for yourself when you're done reading this.

def define_proxy_call(include_private, mod, name, send, *extra)
  defn = "def #{name}(*args)"
  extra = (extra.map!(&:inspect) << "*args").join(", ")
  target = "#{send}(#{extra})"

  mod.module_eval <<-RUBY, __FILE__, __LINE__ + 1
    #{defn}
      #{target}
    end
  RUBY
end

This defines a new method for us. name is the method name being defined, and send is the handler, and extra is the attribute name. The mod argument is a special module Rails generates using generated_attribute_methods, and mixes into our class. Now let's take a moment to look at module_eval. There are three interesting things happening here.

The first is the HEREDOC being used as an argument to a method. This is a tad esoteric, but quite useful in some cases. For example imagine we have a method for embedding JavaScript in a response:

include_js(<<-JS, :minify => true)
  $('#logo').show();
  App.refresh();
JS

This will call include_js with the string "$('#logo').show(); App.refresh();" as the first parameter, and :minify => true as the second parameter. This is very useful technique when generating code in ruby. As an added benefit some editors like TextMate recognize this pattern and will highlight the string properly. Even if you aren't generating code, HEREDOCs are useful for multiline strings.

Now we know what <<-RUBY is doing, what about __FILE__ and __LINE__ + 1? __FILE__ returns the path to this file, and __LINE__ returns the current line number. module_eval accepts these arguments to specify where the evaluated code should be reported as having been evaluated at. This is particularly useful in stack traces.

Finally, let's look at what module_eval is actually evaluating. We can substitute in our values for name_changed?:

mod.module_eval <<-RUBY, __FILE__, __LINE__ + 1
  def name_changed?(*args)
    attribute_changed?("name", *args)
  end
RUBY

Now name_changed? is a real method, which has much less overhead than relying on method_missing.

Recap

We found that calling attribute_method_suffix stores a configuration object used by one of two approaches to metaprogramming in Rails. Regardless of whether method_missing is used, or the method is defined with module_eval, calls will eventually be passed off to a method like attribute_changed?(attr).

While we went down this windy road, we also came across some other useful things:

Keep looking over the Rails source, you never know what you will find.

blog comments powered by Disqus
Monkey Small Crow Small