Modifying Association Methods Dynamically
CommentsRecently, I encountered a bug in ActiveRecord’s AssociationCollection. When you push a new record into an associated collection, the collection in memory is always appended to, even if :uniq is set for the association. This causes a discrepancy in such a collection’s contents until a reload from the database removes the duplicates. While waiting for feedback on the patch I submitted, I wanted to fix the problem in my application. Now, I could have just redefined the AssociationCollection class during my app’s initialization, but that wouldn’t be very interesting. Since Rails associations are full of strange and wonderful magic, this posed a fun little coding exercise for a Saturday morning.
The Problem
So, the application I’m playing around with at the moment has contacts and tickets. A contact has_and_belongs_to_many tickets, and I want to avoid adding duplicate records in my HABTM table. I end up with something like:
class Contact < ActiveRecord::Base
# This :insert_sql is for MySQL and sqlite.
has_and_belongs_to_many :tickets, :uniq => true,
:insert_sql => 'REPLACE INTO contacts_tickets VALUES(#{id},#{record.id})'
end
Nifty. So we’re taking care of duplicates going in, but we’re also doing the usual :uniq, which Rails uses to take care of them on the way out. Let’s give it a shot:
>> c = Contact.find(1)
=> #<Contact id: 1, ...>
>> c.tickets
=> [#<Ticket id: 1, ...>, #<Ticket id: 2, ...>, ...]
>> c.tickets.size
=> 6
>> c.tickets << Ticket.find(1)
=> [#<Ticket id: 1, ...>, #<Ticket id: 2, ...>, ...]
>> c.tickets.size
=> 7
>> c = Contact.find(1)
=> #<Contact id: 1, ...>
>> c.tickets.size
=> 6
And here we find our bug, in which we keep seeing our collection grow despite the :uniq option, until we reload from the database. After some experimentation, we find out that a one line change to AssociationCollection#add_record_to_target_with_callbacks will prevent adding the record to the @target array if it’s already there.
Now, if you’ve spent any time poking around in ActiveRecord’s associations.rb, association_proxy.rb and association_collection.rb, you’ll quickly realize how deep the water you’ve just waded into is. If not, read this nice summary over at err.the_blog, which covers just how your model receives those nifty accessors when you define an association. Of course, the accessors themselves are just one piece of it. The object that the accessors… erm… access is even more interesting. It will be one of a number of descendants of AssociationProxy, which tells us, and I quote:
This class has most of the basic instance methods removed, and delegates unknown methods to @target via method_missing. As a corner case, it even removes the class method and that’s why you get
blog.posts.class # => Array
though the object behind blog.posts is not an Array, but an ActiveRecord::Associations::HasManyAssociation.
Yeah. That’s some seriously messed up stuff. Let it sink in for a minute.
Ready to move on? Good.
So we know we need to get at that association and start mucking about inside it, redefining methods and having our naughty, metaprogramming way with it. And, as Chris explained in his post, we know the usual tricks for method overrides won’t work. We’re not dealing with a method on our own class, but instance methods of an object being accessed by our accessor. And, even that’s not quite right, because as the venerable why has taught us, objects don’t have methods, classes have methods.
The Solution
Continuing on with my contrived example, overriding AssociationCollection#add_record_to_target_with_callbacks in the most difficult way possible, we first need to alias the Contact#tickets method, so that we can access the object it returns when we set about making our changes.
alias_method :real_tickets, :tickets
With that out of the way, we can set about defining our own #tickets method:
def tickets
unless @push_overridden # No sense doing this twice
# We're doing an instance_eval on the metaclass (see why's post
# above) of the AssociationCollection object returned by
# real_tickets to mess with its methods.
(class << real_tickets; self; end).instance_eval do
define_method :add_record_to_target_with_callbacks do |record|
callback(:before_add, record)
yield(record) if block_given?
@target ||= [] unless loaded?
# The "unless" we added here will take care of dupes.
@target << record unless @reflection.options[:uniq] && @target.include?(record)
callback(:after_add, record)
record
end
# The original version was private, let's be consistent
private :add_record_to_target_with_callbacks
end
@push_overridden = true
end
# Now let's use our new, improved AssociationCollection
real_tickets
end
By doing that instance_eval inside the metaclass of our AssociationCollection object, we can have our way with the collection methods, and fix our bug:
>> (c.tickets << Ticket.find(1)).size
=> 6
>> (c.tickets << Ticket.find(1)).size
=> 6
# Since we patched the object's method, even
# calls to real_tickets work properly after 1 call
# to #tickets
>> (c.real_tickets << Ticket.find(1)).size
=> 6
>> (c.real_tickets << Ticket.find(1)).size
=> 6
I’ll leave it as an exercise for the reader to come up with some actual uses for this. I just thought it was kind of a fun experiment. ;)
comments powered by Disqus