Thursday, October 2, 2008

How to has_many :through a has_many :through

So, I’ve got 3 models:

  User -< UserConversation >- Conversation -< Message

  (where -< is has many, >- is belongs to, ... etc)

I wanted, in this application, to be able to filter to all messages for a given user.

  User#messages.find(message_id)

So, naturally, I first reached for this:

  class User < ActiveRecord::Base
    ...
    has_many :user_conversations
    has_many :conversations, :through => :user_conversations
    has_many :messages, :through => :conversations
  end 

  class UserConversation < ActiveRecord::Base
    belongs_to :user
    belongs_to :conversation
  end

  class Conversation < ActiveRecord::Base
    has_many :user_conversations, :dependent => :destroy
    has_many :messages, :dependent => :destroy
  end

  class Message < ActiveRecord::Base
    belongs_to :conversation
  end

Which… is completly WRONG and yields results like the following:


  >> User.first.messages

  ActiveRecord::StatementInvalid: SQLite3::SQLException: no such
  column: conversations.user_id: SELECT "messages".* FROM "messages"
  INNER JOIN conversations ON messages.conversation_id =
  conversations.id    WHERE (("conversations".user_id = 1)) 

So, apparently ActiveRecord doesn’t support joins like that. Sure, I could resort to manually specifying the finder_sql in the join and skip the has_many :through business, but then I’d lose the that cool ability to apply additional scopes after this – major FAIL.

So, I came up with a clever way to get around it.

  class User < ActiveRecord::Base
    ...
    has_many :user_conversations
    has_many :conversations, :through => :user_conversations

    def messages
      Message.for_user(self)
    end
  end

  class Message < ActiveRecord::Base
    belongs_to :conversation
    
    named_scope :for_user, lambda { |user| user = user.id if user.is_a?(User); { :joins => {:conversation => :user_conversations}, :conditions => ["user_conversations.user_id = ?", user]}}
  end

And… now we can do things like:

  user.messages.find(1)
  user.messages.matching_subject("boogy man")

etc., etc. Hurray for named_scope. So.. there's still a few issues. Namely, it's not REALLY an association - no "create" or "build" methods.

Dear web: what do think about this? Got a better way to implement the above scenario?