Skip to content

Destroying bi-directional has_one through association only works from one end #50948

@airblade

Description

@airblade

Summary

Given a has_one :through association with dependent: :destroy, destroying the parent destroys the through record and the far end. However if the association is bi-directional, destroying only works from one end – and the end which succeeds depends on the order of belongs_to declarations in the join model. I believe it should work from both ends.

Steps to reproduce

# frozen_string_literal: true

require "bundler/inline"

gemfile(true) do
  source "https://rubygems.org"

  git_source(:github) { |repo| "https://github.com/#{repo}.git" }

  gem "rails"
  gem "sqlite3"
end

require "active_record"
require "minitest/autorun"
require "logger"

ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
ActiveRecord::Base.logger = Logger.new(STDOUT)

ActiveRecord::Schema.define do
  create_table :lefts, force: true do |t|
  end

  create_table :rights, force: true do |t|
  end

  create_table :middles, force: true do |t|
    t.references :left, foreign_key: true
    t.references :right, foreign_key: true
  end
end

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

class Middle < ActiveRecord::Base
  belongs_to :left, dependent: :destroy
  belongs_to :right, dependent: :destroy
end

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

class BugTest < Minitest::Test
  def test_destroying_left_destroys_right
    left = Left.create!
    right = Right.create!
    middle = Middle.create! left: left, right: right

    left.destroy
    assert right.destroyed?
  end

  def test_destroying_right_destroys_left
    left = Left.create!
    right = Right.create!
    middle = Middle.create! left: left, right: right

    right.destroy
    assert left.destroyed?
  end
end

Expected behavior

I expect:

  • left.destroy to also destroy its middle and its right
  • right.destroy to also destroy its middle and its left

Actual behavior

  • right.destroy destroys its middle and its left
  • left.destroy destroys its middle but 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.

Patch

This patch fixes the behaviour and does not break any existing tests (via bundle exec rake test:sqlite3):

diff --git i/activerecord/lib/active_record/callbacks.rb w/activerecord/lib/active_record/callbacks.rb
index 29c72d1024..6e9c68b747 100644
--- i/activerecord/lib/active_record/callbacks.rb
+++ w/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

Credit for the patch belongs to Alex.

System configuration

Rails version: 7.1.3

Ruby version: 3.3.0

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions