-
Notifications
You must be signed in to change notification settings - Fork 21.4k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Respect the dependent: :destroy_async
when replacing the association
#42452
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||
---|---|---|---|---|
|
@@ -27,9 +27,7 @@ def handle_dependency | |||
load_target.each { |t| t.destroyed_by_association = reflection } | ||||
destroy_all | ||||
when :destroy_async | ||||
load_target.each do |t| | ||||
t.destroyed_by_association = reflection | ||||
end | ||||
load_target.each { |t| t.destroyed_by_association = reflection } | ||||
|
||||
unless target.empty? | ||||
association_class = target.first.class | ||||
|
@@ -117,8 +115,25 @@ def delete_or_nullify_all_records(method) | |||
|
||||
# Deletes the records according to the <tt>:dependent</tt> option. | ||||
def delete_records(records, method) | ||||
if method == :destroy | ||||
case method | ||||
when :destroy | ||||
records.each(&:destroy!) | ||||
update_counter(-records.length) unless reflection.inverse_updates_counter_cache? | ||||
when :destroy_async | ||||
association_class = records.first.class | ||||
primary_key_column = association_class.primary_key.to_sym | ||||
ids = records.collect { |assoc| assoc.public_send(primary_key_column) } | ||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I took this approach from another part of the code. However, I think it may lead to an N+1 query issue. Is it okay to use it like this here? |
||||
|
||||
enqueue_destroy_association( | ||||
owner_model_name: owner.class.to_s, | ||||
owner_id: owner.id, | ||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I assume the most correct thing would be There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Actually, no,
|
||||
association_class: reflection.klass.to_s, | ||||
association_ids: ids, | ||||
association_primary_key_column: primary_key_column, | ||||
ensuring_owner_was_method: options.fetch(:ensuring_owner_was, nil), | ||||
ensuring_owner_destroyed: false | ||||
) | ||||
|
||||
update_counter(-records.length) unless reflection.inverse_updates_counter_cache? | ||||
else | ||||
scope = self.scope.where(reflection.klass.primary_key => records) | ||||
|
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
|
@@ -61,7 +61,7 @@ def replace(record, save = true) | |||||
save &&= owner.persisted? | ||||||
|
||||||
transaction_if(save) do | ||||||
remove_target!(options[:dependent]) if target && !target.destroyed? && assigning_another_record | ||||||
remove!(target, options[:dependent]) if target && !target.destroyed? && assigning_another_record | ||||||
|
||||||
if record | ||||||
set_owner_attributes(record) | ||||||
|
@@ -87,25 +87,42 @@ def set_new_record(record) | |||||
replace(record, false) | ||||||
end | ||||||
|
||||||
def remove_target!(method) | ||||||
def remove!(record, method) | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Shouldn't it be There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I don't understand this comment. Did you mean "if there is a method with" (in other words There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||||||
case method | ||||||
when :delete | ||||||
target.delete | ||||||
record.delete | ||||||
when :destroy | ||||||
target.destroyed_by_association = reflection | ||||||
if target.persisted? | ||||||
target.destroy | ||||||
record.destroyed_by_association = reflection | ||||||
if record.persisted? | ||||||
record.destroy | ||||||
end | ||||||
when :destroy_async | ||||||
record.destroyed_by_association = reflection | ||||||
|
||||||
if record.persisted? | ||||||
primary_key_column = record.class.primary_key.to_sym | ||||||
id = record.public_send(primary_key_column) | ||||||
|
||||||
enqueue_destroy_association( | ||||||
owner_model_name: owner.class.to_s, | ||||||
owner_id: owner.id, | ||||||
association_class: reflection.klass.to_s, | ||||||
association_ids: [id], | ||||||
association_primary_key_column: primary_key_column, | ||||||
ensuring_owner_was_method: options.fetch(:ensuring_owner_was, nil), | ||||||
ensuring_owner_destroyed: false | ||||||
) | ||||||
end | ||||||
else | ||||||
nullify_owner_attributes(target) | ||||||
remove_inverse_instance(target) | ||||||
nullify_owner_attributes(record) | ||||||
remove_inverse_instance(record) | ||||||
|
||||||
if target.persisted? && owner.persisted? && !target.save | ||||||
set_owner_attributes(target) | ||||||
if record.persisted? && owner.persisted? && !record.save | ||||||
set_owner_attributes(record) | ||||||
raise RecordNotSaved.new( | ||||||
"Failed to remove the existing associated #{reflection.name}. " \ | ||||||
"The record failed to save after its foreign key was set to nil.", | ||||||
target | ||||||
record | ||||||
) | ||||||
end | ||||||
end | ||||||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -4,23 +4,32 @@ module ActiveRecord | |
class DestroyAssociationAsyncError < StandardError | ||
end | ||
|
||
# Job to destroy the records associated with a destroyed record in background. | ||
class DestroyAssociationAsyncJob < ActiveJob::Base | ||
queue_as { ActiveRecord.queues[:destroy] } | ||
|
||
discard_on ActiveJob::DeserializationError | ||
|
||
def perform( | ||
owner_model_name: nil, owner_id: nil, | ||
association_class: nil, association_ids: nil, association_primary_key_column: nil, | ||
ensuring_owner_was_method: nil | ||
) | ||
owner_model_name: nil, | ||
owner_id: nil, | ||
association_class: nil, | ||
association_ids: nil, | ||
association_primary_key_column: nil, | ||
ensuring_owner_was_method: nil, | ||
ensuring_owner_destroyed: true | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Adding a new parameter to this job is tricky and should be avoided if we can. If we add a new option what can happen is that during deploy a working with the old version of the job will load the job and will fail because the number of parameters is bigger than the expected in the job. Do we need this new parameter? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If that the case even if there's a default value for the new parameter? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @rafaelfranca The parameter is needed for the case when the association is destroyed, but the parent is not. I'm not sure if we can recognize it without the param. @ghiculescu yes, because running, old code might expect a smaller number of params. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @rafaelfranca ping --^ There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @rafaelfranca ping --^ There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There is no guarantee people will update to 6.1.X, where X is the commit that will accept There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What should I do with this PR? Wait for 7.0, rebase and ping for review or close? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's open a new PR with the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👍, I will prepare the PR tomorrow. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
) | ||
association_model = association_class.constantize | ||
owner_class = owner_model_name.constantize | ||
owner = owner_class.find_by(owner_class.primary_key.to_sym => owner_id) | ||
|
||
if !owner_destroyed?(owner, ensuring_owner_was_method) | ||
raise DestroyAssociationAsyncError, "owner record not destroyed" | ||
# Handle the case when the `has_many` association is replaced with a new | ||
# set. In such situation, respect the `dependent: :destroy_async`, and | ||
# delete removed records in the background. | ||
if ensuring_owner_destroyed | ||
owner_class = owner_model_name.constantize | ||
owner = owner_class.find_by(owner_class.primary_key.to_sym => owner_id) | ||
|
||
if !owner_destroyed?(owner, ensuring_owner_was_method) | ||
raise DestroyAssociationAsyncError, "owner record not destroyed" | ||
end | ||
end | ||
|
||
association_model.where(association_primary_key_column => association_ids).find_each do |r| | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is duplicated inside this same class. Let's extract this logic to a private method. Same happens with the other associations.