Polymorphic has_many :through enhanced 1

Posted by daniel Wed, 26 Sep 2007 17:35:00 GMT

Yesterday I posted an article describing how to do a DRY, readable polymorphic has_many :through join. Since then, I’ve made a few key improvements to them, so I thought I’d share them here.

The key shortcoming that came up as I used my has_interaction collection was that it wasn’t setting the interaction type automatically. This is a big problem, because the following code won’t work:

@user.hidden_messages << message

I guess my tests weren’t as clearly defined as they should have been. What actually happened was that the interaction was being created, but the interaction type stayed null. This makes sense, since I was only specifying it as a selection criteria. The tests only passed because the “hidden_messages” collection was being cached. So the first thing to do is to rewrite the tests so that they actually reload the data from the database to make sure the relationship has been persisted correctly. I’ve only included one test rewrite for brevity:

def test_should_be_able_to_hide_messages
  @dan.hidden_messages << @message_three
  assert_equal @dan.hidden_messages(true).first, @message_three
end

This should apply to all the tests, though. The useful addition there is the (true) which forces the collection to reload from the database.

So how to make this work? Well, reading this good post by Josh Susser of the well-named “has_many :through” blog, I came up with this:

module HasInteractions

  def self.included(base)
    base.extend(ClassMethods)
  end

  module ClassMethods
    def has_interaction(args)
      has_many  (args[:type] + "_" + args[:model].downcase.pluralize).to_sym,
                :through      => :interactions,
                :source       => :victim,
                :source_type  => args[:model],
                :conditions   => "interaction_type = '#{args[:type]}'" do
                  def <<(victim)
                    Interaction.with_scope(:create => { :interaction_type => args[:type] }) { self.concat victim }
                  end
                end
    end

  end

end

That’s nice, and readable, and all that, but it doesn’t work. It throws a NoMethodError because args is not defined anymore by the time we executed “<<”. This makes sense, because args was a local variable to the has_interaction method, so it’s meaningless when we get back from our external code.

Unfortunately, Josh’s examples all involve only unextracted hasmany :through. If I’d written out each relationship explicitly without extracting it out, his code would have worked. However, I have extracted out hasinteractions, and it is more readable, so I’m going to keep it that way. How to get it to work, then? Well, on this front I got some very good help from the excellent #ruby-lang channel on freenode, mainly from 4 people with the handles “oGMo”, “Sepp2k”, “nutrimatt” and “apeiros”. The result was this:

module HasInteractions

  def self.included(base)
    base.extend(ClassMethods)
  end

  module ClassMethods
    def has_interaction(args)
      has_many  (args[:type] + "_" + args[:model].downcase.pluralize).to_sym,
                :through      => :interactions,
                :source       => :victim,
                :source_type  => args[:model],
                :conditions   => "interaction_type = '#{args[:type]}'" do
                  class_eval do
                    define_method("<<") do |victim|
                      Interaction.with_scope(:create => { :interaction_type => args[:type] }) { self.concat victim }
                    end
                  end
                end
    end

  end

end

I won’t go into a detailed explanation of why this works. If you got here looking for how to make it work, this works. If you want to find out why it works, a quick read through the api documentation for classeval and definemethod will clear it up.

There was one more useful improvement that apeiros suggested: The ClassMethods module I had copied from Rails was unnecessary. Instead of “include”ing, I could just “extend” the HasInteractions module and define the has_interactions in there directly. Here’s the resulting code:

module HasInteractions

  def has_interaction(args)
    has_many  (args[:type] + "_" + args[:model].downcase.pluralize).to_sym,
              :through      => :interactions,
              :source       => :victim,
              :source_type  => args[:model],
              :conditions   => "interaction_type = '#{args[:type]}'" do
                class_eval do
                  define_method("<<") do |victim|
                    Interaction.with_scope(:create => { :interaction_type => args[:type] }) { self.concat victim }
                  end
                end
              end
  end

end

And in user.rb, we just replace “include HasInteractions” with “extend HasInteractions” - et voilà! It works. All the tests pass. It’s readable.

I hope you find this article useful and it helps you with what you’re trying to implement. If you have any further suggested improvements, or any comments, feel free to leave them below.

Please vote this article up on social news sites! Why?

Stumble It!
Trackbacks

Use the following link to trackback from your own site:
http://inter-sections.net/trackbacks?article_id=polymorphic-has_many-through-enhanced&day=26&month=09&year=2007

  1. [...] How to be great Polymorphic has_many :through enhanced [...]
Comments

Leave a comment

  1. Avatar
    Jason 3 months later:

    Meant to comment on this post… oh well you get a double thanks. ++++++++++++++++ Thanks… This actually was almost exactly what I was looking for. Made some modifications and implemented one of Josh Susser’s other methods “pushwithattributes” and it works great.

    Thanks for posting!!!

    -J

Comments