How to destroy a bi-directional has_one association from either end?

147 Views Asked by At

Here are my models:

class Left < ApplicationRecord
  has_one :middle, dependent: :destroy
  has_one :right, through: :middle
end

class Middle < ApplicationRecord
  belongs_to :left, dependent: :destroy
  belongs_to :right, dependent: :destroy
end

class Right < ApplicationRecord
  has_one :middle, dependent: :destroy
  has_one :left, through: :middle
end

I would like left.destroy to also destroy its middle and its right. Similarly I would like right.destroy to destroy its middle and its left.

With the setup above, right.destroy does what I want but left.destroy does not destroy its right.

However if I reverse the order of Middle's belongs_to declarations, right.destroy stops working and left.destroy starts working.

How can I get a left-middle-right association to be destroyed from either end?

3

There are 3 best solutions below

1
Alex On BEST ANSWER

Sure looks like a bug. Best I could figure is this rollback breaks the chain:
https://github.com/rails/rails/blob/v7.1.3/activerecord/lib/active_record/associations/belongs_to_association.rb#L12

case options[:dependent]
  when :destroy
    raise ActiveRecord::Rollback unless target.destroy

Everything works fine from both directions, if that rollback isn't raised, which is why having your own callbacks works:

class Middle < ApplicationRecord
  belongs_to :left
  belongs_to :right

  after_destroy -> { left.destroy }
  after_destroy -> { right.destroy }
end

In case you want to try and patch the bug, here's my one attempt:

$ git diff
  diff --git a/activerecord/lib/active_record/callbacks.rb b/activerecord/lib/active_record/callbacks.rb
  index 29c72d1024..6e9c68b747 100644
  --- a/activerecord/lib/active_record/callbacks.rb
  +++ b/activerecord/lib/active_record/callbacks.rb
  @@ -418,7 +418,7 @@ module ClassMethods
   
       def destroy # :nodoc:
         @_destroy_callback_already_called ||= false
  -      return if @_destroy_callback_already_called
  +      return true if @_destroy_callback_already_called
         @_destroy_callback_already_called = true
         _run_destroy_callbacks { super }
       rescue RecordNotDestroyed => e

I only ran activerecord sqlite tests:

bundle exec rake test:sqlite3

Finished in 155.701590s, 55.8568 runs/s, 189.7026 assertions/s.
8697 runs, 29537 assertions, 0 failures, 0 errors, 33 skips
0
Andy Stewart On

I found a solution: in Middle, remove the :dependent options from the belongs_to declarations and add an after_destroy callback which explicitly destroys the left and right associated objects.

class Middle < ApplicationRecord
  belongs_to :left
  belongs_to :right

  after_destroy :destroy_associated

  def destroy_associated
    left.destroy
    right.destroy
  end
end

I don't know if this is the canonical way to do it though.

0
darkinSyde On

According to ActiveRecord documentation here, it says:

Note that :dependent is implemented using Rails’ callback system, which works by processing callbacks in order. Therefore, other callbacks declared either before or after the :dependent option can affect what it does.

So, since we declared belongs_to :right, dependent: :destroy at the end of the model, it processes other callbacks which declared before this one. But for the left, it does not process the post callbacks.

In this situation, I think creating after_destroy method is the best way to handle the problem.