mithunm93
mithunm93

Reputation: 571

ActiveRecord has_many through dependent :destroy issue

I have 3 models that are set up like this:

Class A
  has_many class_b dependent :destroy
  has_many class_c dependent :destroy
end

Class B
  belongs_to class_a
  has_many class_c through class_a conditions: proc {<conditions>} dependent :destroy
end

Class C
  belongs to class_a
end

conditions: only a subset of the class_c objects that belong to class_a also belong
to class_b. class_c has a column that is essentially class_b_id, so only those
instances will be deleted.

I'm trying to destroy an instance of class_b, but I get this error:

ActiveRecord::HasManyThroughCantAssociateThroughHasOneOrManyReflection (Cannot modify 'association ClassB#class_c' because the source reflection class 'ClassC' is associated to 'ClassA' via :has_many.)

What can I do to fix this error? Do I have to rework my associations?

Upvotes: 2

Views: 1819

Answers (2)

mithunm93
mithunm93

Reputation: 571

I didn't want to change the fundamental associations in my codebase so what I ended up doing is removing the dependent: destroy from class_b's association with class_c and just writing a custom query in the before_destroy of class_b

Upvotes: 1

Pavel Bulanov
Pavel Bulanov

Reputation: 953

Honestly, your models configuration is very cont-intuitive. You have parent class A which has may B's and C's. Then, for some reason, you want that deletion of any B (which may be many for a single A) would go to its parent A and delete its all C's (of that parent A). Then, for example, another child B of the same parent A would eventually lose all it's C's by doing nothing.

C's don't look like dependent in such case, structurally they're on the same level as B. And as described, this could lead to some unexpected things - imagine that in parallel you have work with the second B and its C's, while suddenly they all disappear because of the first B.

Providing real names and logic for class associations would make more sense on how to better design relations - but they definitely needs to be changed

Update. has_one or has_many association of the inner (connecting table) can't be used for making through association. So your class_A must belong_to class_C (target class) so Class_B can has_many it through class_A.

Look at Rails sources for through connection implementation, there is a check there for source_reflection - this is connection of the middle (joining) class to the destination (target) one.

  def ensure_mutable
      unless source_reflection.belongs_to? #interim connection must be belongs_to or it fails
        if reflection.has_one?
          raise HasOneThroughCantAssociateThroughHasOneOrManyReflection.new(owner, reflection)
        else
          raise HasManyThroughCantAssociateThroughHasOneOrManyReflection.new(owner, reflection)
        end
      end
    end

You can also check here

Meanwhile, you also can't have belong_to through - see here - see accepted answer

Update 2 - Long-long story reflecting on Rails sources.

First, ActiveRecord::Base class (which you inherit all your models from) includes Associations module (not class!) here:

include Associations

Then, Associations module by using Concerns will add several class methods to the Base class. Specifically, it adds has_many method to your model that you use to build association. This method creates instance of Builder::HasMany class, and passes self (which is reference to class B in your case) and name (which is :class_c symbol), plus all your supporting options, including option[:through] equal to Class_A. Look in sources

  def has_many(name, scope = nil, options = {}, &extension)
    reflection = Builder::HasMany.build(self, name, scope, options, &extension)
    Reflection.add_reflection self, name, reflection
  end

Technically, HasMany is a small class on top of CollectionAssociation class which in its order lays on Association class, all inside Builder:: name-scope (note it's all now classes, different from parent Association module). HasMany

module ActiveRecord::Associations::Builder # :nodoc:
 class HasMany < CollectionAssociation #:nodoc:
    def self.macro
      :has_many
    end

Inheritance of CollectionAssociation

module ActiveRecord::Associations::Builder # :nodoc:
  class CollectionAssociation < Association #:nodoc:

Now, Association class has that build method used above to create has_many reflection in has_many method call above. Let's look at sources. Notice, that it creates a Reflection using create method:

module ActiveRecord::Associations::Builder # :nodoc:
  class Association #:nodoc:
   def self.build(model, name, scope, options, &block)
      ..     
      reflection = create_reflection model, name, scope, options, extension
      ..
      reflection
   end
   def self.create_reflection(model, name, scope, options, extension = nil)
      ..    
      ActiveRecord::Reflection.create(macro, name, scope, options, model)
   end
   ...

Notice, that in your case following parameters are passed: macro would be ":has_many" symbol, name (class_c) will be the first parameter, model was self (class B). It will also have your options[:through] = class_a inside options.

Now let's look at Reflection module create method that was used. BTW, Reflection module is also included into ActiveRecord::Base class, but is references through namespace to avoid naming confusion for .create call. This method will create one of the specific type Reflecton subclasses. sources

module ActiveRecord
  # = Active Record Reflection
  module Reflection # :nodoc:
    extend ActiveSupport::Concern

    def self.create(macro, name, scope, options, ar)
      klass = case macro
              when :composed_of
                AggregateReflection
              when :has_many
                HasManyReflection
              when :has_one
                HasOneReflection
              when :belongs_to
                BelongsToReflection
              else
                raise "Unsupported Macro: #{macro}"
              end

      reflection = klass.new(name, scope, options, ar)
      options[:through] ? ThroughReflection.new(reflection) : reflection
    end

In your case, it will create ThroughReflection.new (because of presence of options[:through]) class instance with inner HasManyReflection instance (because of :has_many value of macro). Inner HasManyReflection will have all the original parameters inside it - name of class c, and ar (seems to mean active_record) is model which was originally set to self (class b), and also a through option as well.

ThroughReflection class is also defined inside same Reflection module here.

 # Holds all the meta-data about a :through association as it was specified
 # in the Active Record class.
 class ThroughReflection < AbstractReflection #:nodoc:

It has the implementation of source_reflection method, which will be used in ensure_mutable call (line 736).

  # Returns the source of the through reflection. It checks both a singularized
  # and pluralized form for <tt>:belongs_to</tt> or <tt>:has_many</tt>.
  #
  #   class Post < ActiveRecord::Base
  #     has_many :taggings
  #     has_many :tags, through: :taggings
  #   end
  #
  #   class Tagging < ActiveRecord::Base
  #     belongs_to :post
  #     belongs_to :tag
  #   end
  #
  #   tags_reflection = Post.reflect_on_association(:tags)
  #   tags_reflection.source_reflection
  #   # => <ActiveRecord::Reflection::BelongsToReflection: @name=:tag, @active_record=Tagging, @plural_name="tags">
  #
  def source_reflection
    through_reflection.klass._reflect_on_association(source_reflection_name)
  end

In your case, source_reflection call will return value of HasManyReflection type as Class A has_many of Class C. Look through the provided comments for method to understand what it does.

See also through_reflection definition on the next lines - it checks for the association you use through to get to the needed class - it's your association from class_b to class_a (class_b belongs to class_a). Then it will check for association from the interim class (class_a) to the final class (class_c) in source_reflection above.

  # Returns the AssociationReflection object specified in the <tt>:through</tt> option
  # of a HasManyThrough or HasOneThrough association.
  #
  #   class Post < ActiveRecord::Base
  #     has_many :taggings
  #     has_many :tags, through: :taggings
  #   end
  #
  #   tags_reflection = Post.reflect_on_association(:tags)
  #   tags_reflection.through_reflection
  #   # => <ActiveRecord::Reflection::HasManyReflection: @name=:taggings, @active_record=Post, @plural_name="taggings">
  #
  def through_reflection
    active_record._reflect_on_association(options[:through])
  end

-- Finally--

Inner HasManyReflection instance of ThroughReflection will return HasManyThroughAssociation class in association_class method - here

class HasManyReflection < AssociationReflection # :nodoc:
      def association_class
        if options[:through]
          Associations::HasManyThroughAssociation
        else
          Associations::HasManyAssociation
        end
      end
    end

That returned association_class name is used within Associations module (again, not class), which as said long above, is included in ActiveRecord::Base. It's used in order to to create an association (and save it to your class_b model) - will be of HasManyThroughAssociation class in your case. HasManyThroughAssociation instance will also have that ThroughReflection inside it passed here in new (see reflection parameter). sources

def association(name) #:nodoc:
   if association.nil? # if was not created before
     unless reflection = self.class._reflect_on_association(name)
        raise AssociationNotFoundError.new(self, name)
     end
     association = reflection.association_class.new(self, reflection)
     association_instance_set(name, association)
   end
   association
 end

_reflect_on_association(name) here loads the previously created reflection (of ThroughReflection) from the local "storage" of the model (class_b) by its name (class_a in your case). So for "class_a" name parameter it has that ThroughReflection as you wrote has_many :class_a.

Also here for the constructor call:

class HasManyThroughAssociation < HasManyAssociation #:nodoc:
      include ThroughAssociation

      def initialize(owner, reflection)
        super

        @through_records     = {}
        @through_association = nil
      end

Just in case, here is the constructor call that will eventually be called by super here:

 class Association #:nodoc:       
      delegate :options, :to => :reflection
      def initialize(owner, reflection)
        @owner, @reflection = owner, reflection
       ...

So it saves passed reflection (which was ThroughReflection).

Now, let's take a look at that HasManyThroughAssociation class that was created and has ThroughReflection inside - sources. It actually includes reusable portion of ThroughAssociation module:

include ThroughAssociation

It also delegates source_reflection and through_reflection calls to it's saved reflection variable - here. As you remember, reflection would be of ThroughReflection class.

  delegate :source_reflection, :through_reflection, :to => :reflection

Included ThroughAssociation, in its case, has that ensure_mutable call that raise exception - here

 def ensure_mutable
      unless source_reflection.belongs_to?
        if reflection.has_one?
          raise HasOneThroughCantAssociateThroughHasOneOrManyReflection.new(owner, reflection)
        else
          raise HasManyThroughCantAssociateThroughHasOneOrManyReflection.new(owner, reflection)
        end
      end
  end

source_reflection call is delegated to saved reflection, which is ThroughReflection And as said above (look for above source_reflection method definition in ThroughReflection), your source_reflection for your ThroughReflection will be of has_many type, as Class A has_many of Class C. So as it's not belongs_to (interim class a not belongs to the target class c).

General comments from the code:

 # Construct attributes for :through pointing to owner and associate. This is used by the
 # methods which create and delete records on the association.
 #
 # We only support indirectly modifying through associations which have a belongs_to source.
 # This is the "has_many :tags, through: :taggings" situation, where the join model
 # typically has a belongs_to on both side. In other words, associations which could also
 # be represented as has_and_belongs_to_many associations.
 #
 # We do not support creating/deleting records on the association where the source has
 # some other type, because this opens up a whole can of worms, and in basically any
 # situation it is more natural for the user to just create or modify their join records
 # directly as required.

Upvotes: 3

Related Questions