Skip to content
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

Add support for recursive delete all associations #326

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/acts_as_paranoid.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ def acts_as_paranoid(options = {})
column: "deleted_at",
column_type: "time",
recover_dependent_associations: true,
handle_delete_all_associations: false,
dependent_recovery_window: 2.minutes,
double_tap_destroys_fully: true
}
Expand Down
84 changes: 77 additions & 7 deletions lib/acts_as_paranoid/core.rb
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,45 @@ def recovery_value
end
end

def recover_dependent_delete_all_associations(scope, deleted_value, recovery_window)
reflect_on_all_associations(:has_many).each do |association|
unless association.options[:dependent] == :delete_all ||
association.options[:dependent] == :destroy
next
end
next unless (klass = association.klass).paranoid?
next unless paranoid_column_type == :time && klass.paranoid_column_type == :time

sub_scope = klass.only_deleted.where(
association.foreign_key => scope.select(:id)
).deleted_inside_time_window(deleted_value, recovery_window)

klass.recover_dependent_delete_all_associations(sub_scope, deleted_value,
recovery_window)
end

scope.update_all(paranoid_column => recovery_value)
end

def recursive_delete_dependent_delete_all_associations(scope,
first_level_child: false)
reflect_on_all_associations(:has_many).each do |association|
unless association.options[:dependent] == :delete_all ||
association.options[:dependent] == :destroy
next
end
next unless (klass = association.klass).paranoid?

sub_scope = klass.where(association.foreign_key => scope.select(:id))

klass.recursive_delete_dependent_delete_all_associations(sub_scope)
end

# We skip this delete_all if it's the first level child,
# as they will be deleted by ActiveRecord automatically.
scope.delete_all unless first_level_child
end

protected

def define_deleted_time_scopes
Expand Down Expand Up @@ -173,6 +212,9 @@ def destroy!
def destroy
if !deleted?
with_transaction_returning_status do
if self.class.paranoid_configuration[:handle_delete_all_associations]
delete_dependent_delete_all_associations
end
run_callbacks :destroy do
if persisted?
# Handle composite keys, otherwise we would just use
Expand All @@ -199,6 +241,8 @@ def recover(options = {})
options = {
recursive: self.class.paranoid_configuration[:recover_dependent_associations],
recovery_window: self.class.paranoid_configuration[:dependent_recovery_window],
handle_delete_all_associations:
self.class.paranoid_configuration[:handle_delete_all_associations],
raise_error: false
}.merge(options)

Expand Down Expand Up @@ -246,6 +290,25 @@ def deleted_fully?

private

def delete_dependent_delete_all_associations
self.class.dependent_associations.each do |reflection|
if reflection.options[:dependent] == :delete_all
delete_dependent_delete_all_association(reflection)
end
end
end

def delete_dependent_delete_all_association(reflection)
association = association(reflection.name)
klass = association.klass
return unless klass.paranoid?

scope = klass.merge(get_association_scope(association))

klass.recursive_delete_dependent_delete_all_associations(scope,
first_level_child: true)
end

def recover_dependent_associations(deleted_value, options)
self.class.dependent_associations.each do |reflection|
recover_dependent_association(reflection, deleted_value, options)
Expand All @@ -266,7 +329,6 @@ def destroy_dependent_associations!
def recover_dependent_association(reflection, deleted_value, options)
assoc = association(reflection.name)
return unless (klass = assoc.klass).paranoid?

if reflection.belongs_to? && attributes[reflection.association_foreign_key].nil?
return
end
Expand All @@ -279,13 +341,21 @@ def recover_dependent_association(reflection, deleted_value, options)
scope = scope.deleted_inside_time_window(deleted_value, options[:recovery_window])
end

recovered = false
scope.each do |object|
object.recover(options)
recovered = true
end
if options[:handle_delete_all_associations] &&
reflection.options[:dependent] == :delete_all
scope.klass.dependent_associations.each do |_dependent_association|
scope.klass.recover_dependent_delete_all_associations(scope, deleted_value,
options[:recovery_window])
end
else
recovered = false
scope.each do |object|
object.recover(options)
recovered = true
end

assoc.reload if recovered && reflection.has_one? && assoc.loaded?
assoc.reload if recovered && reflection.has_one? && assoc.loaded?
end
end

def get_association_scope(dependent_association)
Expand Down
169 changes: 169 additions & 0 deletions test/test_delete_all.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
# frozen_string_literal: true

require "test_helper"

class ParanoidParent < ActiveRecord::Base
acts_as_paranoid(handle_delete_all_associations: true)

has_many :paranoid_children, dependent: :delete_all
end

class UnhandledDeleteParanoidParent < ActiveRecord::Base
acts_as_paranoid

has_many :paranoid_children, dependent: :delete_all
end


class ParanoidChild < ActiveRecord::Base
acts_as_paranoid(handle_delete_all_associations: true)

belongs_to :paranoid_parent
has_many :paranoid_grandchildren, dependent: :delete_all
end

class ParanoidGrandchild < ActiveRecord::Base
acts_as_paranoid(handle_delete_all_associations: true)

belongs_to :paranoid_child
end

class ActsAsParanoidTest < ActiveSupport::TestCase
def setup_db
ActiveRecord::Schema.define(version: 1) do
create_table :paranoid_parents do |t|
t.datetime :deleted_at
t.timestamps
end

create_table :unhandled_delete_paranoid_parents do |t|
t.datetime :deleted_at
t.timestamps
end

create_table :paranoid_children do |t|
t.references :paranoid_parent, foreign_key: { on_delete: :cascade }
t.references :unhandled_delete_paranoid_parent, foreign_key: { on_delete: :cascade }
t.datetime :deleted_at
t.timestamps
end

create_table :paranoid_grandchildren do |t|
t.references :paranoid_child, foreign_key: { on_delete: :cascade }
t.datetime :deleted_at
t.timestamps
end
end
end

def setup
setup_db
end

def teardown
teardown_db
end

def test_deletes_all_children_when_deleting_parent
parent = create_parent_with_children_and_grandchildren(child_count: 5)

assert_difference("ParanoidChild.count", -5) { parent.destroy }
end

def test_soft_deletes_all_children_when_deleting_parent
parent = create_parent_with_children_and_grandchildren(child_count: 5)

assert_no_difference("ParanoidChild.with_deleted.count") { parent.destroy }
end

def test_deletes_all_grandchildren_when_deleting_parent
parent = create_parent_with_children_and_grandchildren(child_count: 5)

assert_difference("ParanoidGrandchild.count", -25) { parent.destroy }
end

def test_soft_deletes_all_grandchildren_when_deleting_parent
parent = create_parent_with_children_and_grandchildren(child_count: 5)

assert_no_difference("ParanoidGrandchild.with_deleted.count") { parent.destroy }
end

def test_makes_one_query_for_each_object_type_when_deleting_parent
parent = create_parent_with_children_and_grandchildren(child_count: 5)

query_count = count_queries do
parent.destroy
end

# 1 query for parent, 1 for children, 1 for grandchildren, and 2 for the SQL TRANSACTION
assert_equal(5, query_count)
end

def test_unsets_deleted_at_on_all_children_when_recovering_parent
parent = create_parent_with_children_and_grandchildren(child_count: 5,
deleted_at: Time.current)

assert_difference("ParanoidChild.count", 5) { parent.recover }
end

def test_unsets_deleted_at_on_all_grandchildren_when_restoring_parent
parent = create_parent_with_children_and_grandchildren(child_count: 5,
deleted_at: Time.current)

assert_difference("ParanoidGrandchild.count", 25) { parent.recover }
end

def test_makes_one_query_for_each_object_type_when_recovering_parent
parent = create_parent_with_children_and_grandchildren(child_count: 5,
deleted_at: Time.current)

query_count = count_queries do
parent.recover
end

# 1 query for parent, 1 for children, 1 for grandchildren, 2 for the SQL TRANSACTION
assert_equal(5, query_count)
end

def test_does_not_delete_all_grandchildren_when_handle_is_false
parent = UnhandledDeleteParanoidParent.create

child = parent.paranoid_children.create
child.paranoid_grandchildren.create

assert_difference("ParanoidGrandchild.count", 0) { parent.destroy }
end

private

def count_queries(&block)
count = 0

counter_f = lambda { |_name, _started, _finished, _unique_id, payload|
count += 1 unless payload[:name].in? %w[CACHE SCHEMA]
}

ActiveSupport::Notifications.subscribed(counter_f, "sql.active_record", &block)

count
end

def create_parent_with_children_and_grandchildren(child_count:, deleted_at: nil)
parent = ParanoidParent.create

child_count.times do
child = parent.paranoid_children.create
child_count.times do
child.paranoid_grandchildren.create
end
end

if deleted_at
ParanoidParent.update_all(deleted_at: deleted_at)
ParanoidChild.update_all(deleted_at: deleted_at)
ParanoidGrandchild.update_all(deleted_at: deleted_at)
end

parent.reload
end
end