diff --git a/lib/acts_as_paranoid.rb b/lib/acts_as_paranoid.rb index b84ddcc..26721a8 100644 --- a/lib/acts_as_paranoid.rb +++ b/lib/acts_as_paranoid.rb @@ -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 } diff --git a/lib/acts_as_paranoid/core.rb b/lib/acts_as_paranoid/core.rb index dc824fc..596a059 100644 --- a/lib/acts_as_paranoid/core.rb +++ b/lib/acts_as_paranoid/core.rb @@ -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 @@ -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 @@ -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) @@ -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) @@ -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 @@ -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) diff --git a/test/test_delete_all.rb b/test/test_delete_all.rb new file mode 100644 index 0000000..50bc504 --- /dev/null +++ b/test/test_delete_all.rb @@ -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