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
lets 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:
has_default_association
let’s you define default associated records.- ActiveSupport::Concern provides a nice way to mix in class methods.
extract_options!
andassert_valid_keys
are helpful for working with options.association(name)
lets us introspect on associations.
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.