Problem with has_many :through

October 21st, 2006

I recently ran into a problem using has_many :through relationships. The edge code works fine when using standard id’s but for those using legacy databases or non-standard id’s in your join table the code fails when trying to add or delete an association.

Something like this would fail:

create_table :books, :force => true do |t|
  t.column :name, :string
end

create_table :citations, :id => false, :force => true do |t|
  t.column :book1_id, :integer
  t.column :book2_id, :integer
end

class Book < ActiveRecord::Base
  has_many :citations, :foreign_key => 'book1_id'  
  has_many :references, :through => :citations, :source => :reference_of, :uniq => true
end

class Citation < ActiveRecord::Base
  belongs_to :reference_of, :class_name => "Book", :foreign_key => :book2_id 
  belongs_to :book1, :class_name => "Book", :foreign_key => :book1_id 
  belongs_to :book2, :class_name => "Book", :foreign_key => :book2_id 
end

awdr = Book.create!(:name => "Agile Web Development with Rails")
rfr = Book.create!(:name => "Ruby for Rails")

awdr.references << rfr
awdr.delete(rfr)

There’s further information at http://dev.rubyonrails.org/ticket/6466

If you’re running into this problem you can patch your local version of rails. First freeze edge in your tree. Then create the file has_many_through_patch.rb in your lib directory with the following code:

module ActiveRecord

  class HasManyThroughCantDisassociateNewRecords < ActiveRecordError #:nodoc:
    def initialize(owner, reflection)
      super("Cannot disassociate new records through '#{owner.class.name}##{reflection.name}' on '#{reflection.source_reflection.class_name rescue nil}##{reflection.source_reflection.name rescue nil}'. Both records must have an id in order to delete the has_many :through record associating them.")
    end
  end

  module Associations
    class HasManyThroughAssociation

      # Construct attributes for :through pointing to owner and associate.
      def construct_join_attributes(associate)
        construct_owner_attributes(@reflection.through_reflection).merge(@reflection.source_reflection.primary_key_name => associate.id)
      end

      # Remove +records+ from this association.  Does not destroy +records+.
      def delete(*records)
         return if records.empty?
         records.each { |associate| raise_on_type_mismatch(associate) }
         through = @reflection.through_reflection
         raise ActiveRecord::HasManyThroughCantDisassociateNewRecords.new(@owner, through) if @owner.new_record?

         load_target

         klass = through.klass
         klass.transaction do
           flatten_deeper(records).each do |associate|
             raise_on_type_mismatch(associate)
             raise ActiveRecord::HasManyThroughCantDisassociateNewRecords.new(@owner, through) unless associate.respond_to?(:new_record?) && !associate.new_record?

             @owner.send(@reflection.through_reflection.name).proxy_target.delete(klass.delete_all(construct_join_attributes(associate)))
             @target.delete(associate)
           end
         end

         self  
       end
    end
  end
end

Then in your environment.rb add the following:

require 'has_many_through_patch'

You should be able to add and delete now until the patch is committed.