Add support for bidirectional destroy dependencies

Prior to this commit if you defined a bidirectional relationship
between two models with destroy dependencies on both sides, a call to
`destroy` would result in an infinite callback loop.

Take the following relationship.

    class Content < ActiveRecord::Base
      has_one :content_position, dependent: :destroy
    end

    class ContentPosition < ActiveRecord::Base
      belongs_to :content, dependent: :destroy
    end

Calling `Content#destroy` or `ContentPosition#destroy` would result in
an infinite callback loop.

This commit changes the behaviour of `ActiveRecord::Callbacks#destroy`
so that it guards against subsequent callbacks.

Thanks to @zetter for demonstrating the issue with failing tests[1].

[1] rails#13609
This commit is contained in:
Seb Jacobs 2015-01-15 23:03:23 +00:00
parent 090c5211ce
commit d5bf649a53
7 changed files with 119 additions and 1 deletions

View File

@ -1,3 +1,19 @@
* Add support for bidirectional destroy dependencies.
Fixes #13609.
Example:
class Content < ActiveRecord::Base
has_one :position, dependent: :destroy
end
class Position < ActiveRecord::Base
belongs_to :content, dependent: :destroy
end
*Seb Jacobs*
* `time` columns can now affected by `time_zone_aware_attributes`. If you have
set `config.time_zone` to a value other than `'UTC'`, they will be treated
as in that time zone by default in Rails 5.0. If this is not the desired

View File

@ -289,7 +289,14 @@ module ActiveRecord
end
def destroy #:nodoc:
_run_destroy_callbacks { super }
begin
@_destroy_callback_already_called ||= false
return if @_destroy_callback_already_called
@_destroy_callback_already_called = true
_run_destroy_callbacks { super }
ensure
@_destroy_callback_already_called = false
end
end
def touch(*) #:nodoc:

View File

@ -0,0 +1,41 @@
require 'cases/helper'
require 'models/content'
class BidirectionalDestroyDependenciesTest < ActiveRecord::TestCase
fixtures :content, :content_positions
def setup
Content.destroyed_ids.clear
ContentPosition.destroyed_ids.clear
end
def test_bidirectional_dependence_when_destroying_item_with_belongs_to_association
content_position = ContentPosition.find(1)
content = content_position.content
assert_not_nil content
content_position.destroy
assert_equal [content_position.id], ContentPosition.destroyed_ids
assert_equal [content.id], Content.destroyed_ids
end
def test_bidirectional_dependence_when_destroying_item_with_has_one_association
content = Content.find(1)
content_position = content.content_position
assert_not_nil content_position
content.destroy
assert_equal [content.id], Content.destroyed_ids
assert_equal [content_position.id], ContentPosition.destroyed_ids
end
def test_bidirectional_dependence_when_destroying_item_with_has_one_association_fails_first_time
content = ContentWhichRequiresTwoDestroyCalls.find(1)
2.times { content.destroy }
assert_equal content.destroyed?, true
end
end

View File

@ -0,0 +1,3 @@
content:
id: 1
title: How to use Rails

View File

@ -0,0 +1,3 @@
content_positions:
id: 1
content_id: 1

View File

@ -0,0 +1,40 @@
class Content < ActiveRecord::Base
self.table_name = 'content'
has_one :content_position, dependent: :destroy
def self.destroyed_ids
@destroyed_ids ||= []
end
before_destroy do |object|
Content.destroyed_ids << object.id
end
end
class ContentWhichRequiresTwoDestroyCalls < ActiveRecord::Base
self.table_name = 'content'
has_one :content_position, foreign_key: 'content_id', dependent: :destroy
after_initialize do
@destroy_count = 0
end
before_destroy do
@destroy_count += 1
if @destroy_count == 1
throw :abort
end
end
end
class ContentPosition < ActiveRecord::Base
belongs_to :content, dependent: :destroy
def self.destroyed_ids
@destroyed_ids ||= []
end
before_destroy do |object|
ContentPosition.destroyed_ids << object.id
end
end

View File

@ -216,6 +216,14 @@ ActiveRecord::Schema.define do
add_index :companies, [:firm_id, :type], name: "company_partial_index", where: "rating > 10"
add_index :companies, :name, name: 'company_name_index', using: :btree
create_table :content, force: true do |t|
t.string :title
end
create_table :content_positions, force: true do |t|
t.integer :content_id
end
create_table :vegetables, force: true do |t|
t.string :name
t.integer :seller_id