Friday, April 14

has_many :revelations

Update: On the drive home tonight, it dawned on me that there's two ways to drastically improve this little hack. I'm trying to make them work right now and I'll post a new entry revealing them as soon as possible.

It wasn't easy, and it's not pretty, but I did it! I managed to achieve my Holy Grail of has_many functionality. (Read my last two posts if you're not familiar with the problem I was having.) Here's how I did it.

In true TDD fashion, I decided on how I wanted to call the API first and wrote a test:

def test_has_many_extensions
collector = Collector.find(1)
collectible = Collectible.find(3)
assert ! collector.possessions._include?( collectible )

collector.possessions._add( collectible )
assert collector.possessions._include?( collectible )

collector = Collector.find(1)
assert collector.possessions._include?( collectible )

collector.possessions._delete( collectible )
assert ! collector.possessions._include?( collectible )

collector = Collector.find(1)
assert ! collector.possessions._include?( collectible )
end

As you can see, I simply took the existing common has_many helper method names (which normally take instances of the joining Model as an argument) and prefixed them with underscores then instead passed in instances of the Model to be mapped to/from.

This, in my mind, seems like a more logical and readable way of "conversing" with the API. I don't want to "add a Possession to a Collector's Possessions." I want to "add a Collectible to a Collector's Possessions." Get it?

So the first thing I had to do was delve into the guts of Rails and start trying to reverse-engineer how everything worked. That was painful. It was excruciating. I'm used to working with Eclipse at the office and navigating Java projects. Eclipse makes it easy to trace up and down call stacks, bookmark points of interest, etc. No such luck with Rails. Even though I was using RadRails (which is built on the Eclipse platform), it doesn't yet offer such niceties [and might not ever due to the complications of parsing and pre-processing a vibrantly dynamic scripting language like Ruby, but I'll keep my toes out of that flame pool for now].

I'll save you the gory details, not so much out of mercy but more from the fact that I couldn't retain it all. My head was swimming trying to keep it all together, and I never really grok'd it completely. But, what's important is that I figured out enough of it to extend it with the functionality I desired.

My new underscore-prefixed methods needed to be added to ActiveRecord::Associations::AssociationCollection, so I placed the following in my "config/environment.rb" file:

module ActiveRecord
module Associations
class AssociationCollection

def _add( widget )
create( { teds_foreign_key => widget.id } )
end

def mapping( widget )
instance_eval "#{ @reflection.klass }.find_by_#{ teds_foreign_key }( #{ widget.id } )"
end

def _delete( widget )
delete( mapping( widget ) )
end

def _include?( widget )
include?( mapping( widget ) )
end

end
end
end

If you're actually reading the code, you'll notice some scary stuff in there. First of note is the eval statement. I needed to make a call to a method that was defined at runtime by the framework, so I don't know beforehand what the name of that method is, and that method's name is based on the model being referenced, so it differs with context. Such is the paradoxical beauty and horror of Ruby. So I formatted the call I needed to make in a string using variables that would be populated at run-time to fill in the holes. This is how I get instances of the mapping Model necessary for passing in to the default has_many helper methods.

The second scary thing is the mysterious "teds_foreign_key" references. I tried, truly I did, but could not find any way in the existing has_many code to extract the foreign key column name. I know it's in there. It has to be in order for it do generate the proper database queries. But I couldn't find it. Undoubtedly it's buried in some abstraction of a library that creates the code for another library on the fly and eval's it all ;-) Perhaps some enlightened reader will set me straight. But, in the meantime, I had to cheat the system. I am passing in a clue to the foreign key from the Model declaration:

class Collector < ActiveRecord::Base

has_many :possessions, :referenced_class => Collectible

...

That "referenced_class" option is what I added to the has_many signature. I use it to deduce the foreign key (and not very elegantly, admittedly) so the aforementioned methods can use it. Here's how I accomplished that (also in my "config/environment.rb" file):

module ActiveRecord
module Associations
module ClassMethods

alias_method :old_has_many, :has_many

def has_many( association_id, options = {}, &extension )
if options[:referenced_class]
@@teds_foreign_key = options[:referenced_class].to_s.downcase + '_id'
end
old_has_many( association_id, options = {}, &extension )
end

def teds_foreign_key
@@teds_foreign_key
end

end
end
end

And that's it! I tested it from the console and everything worked fine; I queried the database after every step as a sanity check. And, of course, the unit test at the top of this post ran without error. I'm pretty fricken psyched about this. If you like it and it helps you out, please let me know. If you see some sort of problem with it, please also speak up. I'm certainly no Ruby or Rails expert [yet] and I'd love to hear the advice of others.

No comments: