diff --git a/lib/paranoia.rb b/lib/paranoia.rb index b69b4402..f7775b7f 100644 --- a/lib/paranoia.rb +++ b/lib/paranoia.rb @@ -60,6 +60,46 @@ def restore(id_or_ids, opts = {}) end end + def paranoia_update(attributes) + attributes = attributes.with_indifferent_access + object_class = self.class + + has_many_through_associations = object_class.reflect_on_all_associations.select do |association| + association.is_a?(ActiveRecord::Reflection::ThroughReflection) + end + + transaction do + run_callbacks(:update) do + has_many_through_associations.each do |association| + association_key = + "#{object_class.reflect_on_association(association.name).foreign_key.pluralize}" + link_table_class = association.through_reflection.klass + next if !attributes.key?(association_key) || !link_table_class.paranoid? + + association_id_array = attributes[association_key].reject(&:blank?) + link_table_plural_name = association.through_reflection.plural_name.to_sym + object_key = "#{object_class.reflect_on_association(link_table_plural_name).foreign_key}" + object_primary_key = association.active_record.primary_key.to_sym + + link_table_objects_to_be_soft_deleted = + link_table_class + .where(object_key => public_send(object_primary_key)) + .where.not(association_key.singularize => association_id_array) + link_table_objects_to_be_soft_deleted.map(&:paranoia_destroy) + end + + self.attributes = attributes + self.save + end + end + end + alias_method :update, :paranoia_update + + def paranoia_update!(attributes) + paranoia_update(attributes) || + raise(ActiveRecord::RecordNotDestroyed.new("Failed to update the record", self)) + end + def paranoia_destroy with_transaction_returning_status do result = run_callbacks(:destroy) do diff --git a/test/paranoia_test.rb b/test/paranoia_test.rb index 14be4d0e..a67be3fe 100644 --- a/test/paranoia_test.rb +++ b/test/paranoia_test.rb @@ -35,9 +35,15 @@ def setup! 'association_with_abort_models' => 'deleted_at DATETIME', 'related_models' => 'parent_model_id INTEGER, parent_model_with_counter_cache_column_id INTEGER, deleted_at DATETIME', 'asplode_models' => 'parent_model_id INTEGER, deleted_at DATETIME', - 'employers' => 'name VARCHAR(32), deleted_at DATETIME', + 'employers' => 'name VARCHAR(32), employee_count INTEGER, date_established DATETIME, number_of_offices INTEGER, deleted_at DATETIME', 'employees' => 'deleted_at DATETIME', 'jobs' => 'employer_id INTEGER NOT NULL, employee_id INTEGER NOT NULL, deleted_at DATETIME', + 'purchases' => 'deleted_at DATETIME', + 'purchase_orders' => 'employer_id INTEGER NOT NULL, purchase_id INTEGER NOT NULL, deleted_at DATETIME', + 'suppliers' => 'deleted_at DATETIME', + 'accounts' => 'employer_id INTEGER NOT NULL, supplier_id INTEGER NOT NULL, deleted_at DATETIME', + 'candidates' => 'deleted_at DATETIME', + 'interviews' => 'employer_id INTEGER NOT NULL, candidate_id INTEGER NOT NULL, deleted_at DATETIME', 'custom_column_models' => 'destroyed_at DATETIME', 'custom_sentinel_models' => 'deleted_at DATETIME NOT NULL', 'non_paranoid_models' => 'parent_model_id INTEGER', @@ -501,6 +507,93 @@ def test_default_scope_for_has_many_through_relationships assert_equal 0, employee.employers.count end + def test_link_table_retains_records_with_update_call + employer = employer_with_associations + + assert_equal 3, employer.jobs.count + assert_equal 3, employer.employees.count + assert_equal 1, employer.employees.first.jobs.count + assert_equal 1, employer.employees.second.jobs.count + + employer.update(employee_ids: [3,4,5], name: 'Google') + + assert_equal 5, Employee.count + assert_equal [3,4,5], Job.all.map(&:employee_id) + assert_equal [1,2], Job.deleted.map(&:employee_id) + assert_equal 'Google', employer.name + end + + def test_link_table_does_not_retains_records_with_update_call_when_not_paranoid + employer = employer_with_associations + + assert_equal 2, employer.purchases.count + assert_equal 2, employer.purchase_orders.count + + employer.update(purchase_ids: [], name: 'Apple') + + assert_equal 0, PurchaseOrder.unscoped.count + assert_equal [1,2], Purchase.all.map(&:id) + assert_equal 'Apple', employer.name + end + + def test_update_on_associations_with_dependent_destroy_when_not_paranoid + employer = employer_with_associations + + assert_equal 2, PurchaseOrder.count + assert_equal 2, Purchase.count + + employer.update(purchase_ids: []) + + assert_equal 0, PurchaseOrder.count + assert_equal 2, Purchase.count + end + + def test_update_on_associations_with_dependent_destroy_when_paranoid + employer = employer_with_associations + + assert_equal 1, Interview.count + assert_equal 1, Candidate.count + + employer.update(candidate_ids: []) + + assert_equal 1, Interview.unscoped.count + assert_equal 1, Candidate.count + end + + def test_update_on_associations_without_dependent_destroy_when_paranoid + employer = employer_with_associations + + assert_equal 3, Job.count + assert_equal 5, Employee.count + + employer.update(employee_ids: []) + + assert_equal 0, Job.count + assert_equal 5, Employee.count + end + + def test_update_on_associations_without_dependent_destroy_when_not_paranoid + employer = employer_with_associations + + assert_equal 1, Account.count + assert_equal 1, Supplier.count + + employer.update(supplier_ids: []) + + assert_equal 0, Account.unscoped.count + assert_equal 1, Supplier.count + end + + def test_update_on_top_level_attributes + employer = employer_with_associations + + employer.update(name: 'Apple', employee_count: 123, date_established: Date.yesterday) + assert_equal 'Apple', employer.name + assert_equal 123, employer.employee_count + assert_equal Date.yesterday, employer.date_established + assert_equal 6, employer.number_of_offices # remains unchanged + end + def test_delete_behavior_for_callbacks model = CallbackModel.new model.save @@ -1256,9 +1349,41 @@ def test_has_one_with_scope_not_restored end private + def get_featureful_model FeaturefulModel.new(:name => "not empty") end + + def employer_with_associations + employer = Employer.create( + name: 'Bellroy', + employee_count: 100, + date_established: Date.today, + number_of_offices: 6 + ) + + employee_1 = Employee.create(id: 1) + employee_2 = Employee.create(id: 2) + employee_3 = Employee.create(id: 3) + employee_4 = Employee.create(id: 4) + employee_5 = Employee.create(id: 5) + job_1 = Job.create :employer => employer, :employee => employee_1 + job_2 = Job.create :employer => employer, :employee => employee_2 + job_3 = Job.create :employer => employer, :employee => employee_3 + + purchase_1 = Purchase.create(id: 1) + purchase_2 = Purchase.create(id: 2) + purchase_order_1 = PurchaseOrder.create :employer => employer, :purchase => purchase_1 + purchase_order_2 = PurchaseOrder.create :employer => employer, :purchase => purchase_2 + + candidate = Candidate.create + inteview = Interview.create :employer => employer, :candidate => candidate + + supplier = Supplier.create + account = Account.create :employer => employer, :supplier => supplier + + employer + end end # Helper classes @@ -1414,11 +1539,24 @@ def set_after_commit_on_destroy_callback_called end end +# Classes | Paranoid? | Dependant Destroy? +#-------------------------------------------------------- +# Account/Supplier | X | X +# Employee/Job | ✓ | X +# Purchase/PurchaseOrder | X | ✓ +# Interview/Candidate | ✓ | ✓ + class Employer < ActiveRecord::Base acts_as_paranoid validates_uniqueness_of :name has_many :jobs has_many :employees, :through => :jobs + has_many :purchase_orders, dependent: :destroy + has_many :purchases, dependent: :destroy, :through => :purchase_orders + has_many :interviews, dependent: :destroy + has_many :candidates, dependent: :destroy, :through => :interviews + has_many :accounts + has_many :suppliers, :through => :accounts end class Employee < ActiveRecord::Base @@ -1433,6 +1571,38 @@ class Job < ActiveRecord::Base belongs_to :employee end +class PurchaseOrder < ActiveRecord::Base + belongs_to :employer + belongs_to :purchase +end + +class Purchase < ActiveRecord::Base + has_many :purchase_orders + has_many :employers, :through => :purchase_orders +end + +class Interview < ActiveRecord::Base + acts_as_paranoid + belongs_to :employer + belongs_to :candidate +end + +class Candidate < ActiveRecord::Base + acts_as_paranoid + has_many :interviews + has_many :employers, :through => :interviews +end + +class Account < ActiveRecord::Base + belongs_to :employer + belongs_to :supplier +end + +class Supplier < ActiveRecord::Base + has_many :accounts + has_many :employers, :through => :accounts +end + class CustomColumnModel < ActiveRecord::Base acts_as_paranoid column: :destroyed_at end