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:
- You should use
Hash#fetch
when reading options, especially booleans. - Format strings such as
"%s_changed"
can be used as simple templates. - Regular Expressions can be escaped with
Regexp.escape
. - Ruby calls
method_missing
if you try to invoke an undefined method. - HEREDOCs can be method arguments, but defined on subsequent lines.
__FILE__
and__LINE__
reference the current file and line number.- You can dynamically generate code using
module_eval
.
Keep looking over the Rails source, you never know what you will find.
More articles in this series
- Reading Rails - HTTP DELETEs With a Link
- Reading Rails - Time Travel
- Reading Rails - TimeWithZone
- Reading Rails - How Does MessageEncryptor Work?
- Reading Rails - How Does MessageVerifier Work?
- Reading Rails - How Do Batched Queries Work?
- Reading Rails - The Adapter Pattern
- Reading Rails - Errors and Validators
- Reading Rails - How Validations Are Configured
- Reading Rails - Concern
- Reading Rails - More Migrations
- Reading Rails - Migrations
- Reading Rails - Attribute Methods
- Reading Rails - Change Tracking
- Reading Rails - Handling Exceptions