HasDefaultAssociations

Setting default values in ActiveRecord is a bit trickier than one would think. You can override ActiveRecord::Base#initialize, but Rails bypasses initialize when returning persisted records. It gets a bit trickier when dealing with associations.

has_default_associations makes this easy without introducing much magic. In fact, we're going to read its source, see a tiny bit of metaprogramming, and learn how to introspect on ActiveRecord associations.

Using HasDefaultAssociation

Imagine you have a User record. For every user, you want a default Bio, even if it's just blank. has_default_association let's you declare a default Bio like this:

class User
  include HasDefaultAssociation

  has_one :bio
  has_default_association(:bio)
end

default_bio = User.new.bio

Rather contrived, but it will do for now.

Including Magic

The first step to using HasDefaultAssociation above is including it, so let's see what's happening there:

module HasDefaultAssociation
  extend ActiveSupport::Concern

  module ClassMethods
    # ...
  end
end

This module uses ActiveSupport::Concern to mix in functionality. Since there are no instance methods declared, it will add any methods in the ClassMethods module ad class methods to whichever class includes HasDefaultAssociation.

Dealing With Arguments

The primary method of interest is has_default_association:

def has_default_association *names, &default_proc
  opts = names.extract_options!
  opts.assert_valid_keys(:eager)

  names.each do |name|
    create_default_association(name, default_proc)
    add_default_association_callback(name) if opts[:eager]
  end
end

This is the method we called above to declare our default Bio.

The arguments are a variable number of names, and default_proc, an optional block. The asterisk means there can be zero or more values for names, and the ampersand means that any block passed in will be assigned to default_proc.

names unfortunately is a bit misleading. This method actually accepts an options Hash as its last argument. Calling extract_options! will pop the last item off an array if it's a Hash, otherwise it just returns an empty Hash.

[:x, :y].extract_options!   
# => {}

[:x, :y, {:a => 1, :b => 2}].extract_options!
# => {:a=>1, :b=>2}

assert_valid_keys will raise an exception if any keys passed in don't match the ones we expect, :eager in this case. This is helpful for preventing errors when calling your method. It would be a pity if a user passed in :edgar => true, thinking they were enabling the :eager option.

If you're only targeting Ruby 2.x, you can achieve this more succinctly with keyword arguments.

A Touch of Magic

For each of the associations referenced by names we call create_default_association. This is where all the magic lives.

def create_default_association name, default_proc
  setter = :"#{name}="

  #...

  define_method(name) do
    target = association(name).load_target
    return target unless target.blank?

    self.send setter, default_proc.call(self)
  end
end

Calling define_method will actually overwrite the name method with the code in the block. So what does this version do? association(name) will get the object representing this association, and then load the associated record with load_target. If no record is returned, then HasDefaultAssociation will assign a new default record by calling the setter.

Let's take a moment and investigate association(name) some more. This returns a subclass of ActiveRecord::Associations::Association. Open up ActiveRecord's lib/associations/association for more details. Each type of association extends this with specialized implementation. If you were ever curious where methods like build came from, look no further.

In our case, we call load_target which will in turn calls find_target if the records haven't been loaded yet. Thanks to a common interface, it doesn't matter if we're operating on a SingularAssociation or a CollectionAssociation, they both know how to find related records:

# SingularAssociation
def find_target
  if record = get_records.first
    set_inverse_instance record
  end
end

# CollectionAssociation
def find_target
  records = get_records
  records.each { |record| set_inverse_instance(record) }
  records
end

Linger here, I guarantee you'll learn something about ActiveRecord.

Recap

That's most of HasDefaultAssociation. Here are some highlights we came across:

A little metaprogramming can go a long way. Want to know more about how HasDefaultAssociation works? You can read the whole class in just a few minutes.

blog comments powered by Disqus
Monkey Small Crow Small