diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..f0efff6d --- /dev/null +++ b/.travis.yml @@ -0,0 +1,7 @@ +language: ruby +rvm: + - 1.9.3 + - 2.0.0 + - 2.1.0 + - jruby-19mode + - rbx \ No newline at end of file diff --git a/Gemfile b/Gemfile index 1a354a68..5c9b8f0c 100644 --- a/Gemfile +++ b/Gemfile @@ -1,4 +1,13 @@ -source :gemcutter +source 'https://rubygems.org' + +gem 'sqlite3', :platforms => [:ruby] +gem 'activerecord-jdbcsqlite3-adapter', :platforms => [:jruby] + +platforms :rbx do + gem 'rubysl', '~> 2.0' + gem 'rubysl-test-unit' + gem 'rubinius-developer_tools' +end # Specify your gem's dependencies in paranoia.gemspec gemspec diff --git a/README.md b/README.md index 0090241c..d44ce9c1 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,30 @@ Paranoia is a re-implementation of [acts\_as\_paranoid](http://github.com/techno You would use either plugin / gem if you wished that when you called `destroy` on an Active Record object that it didn't actually destroy it, but just "hid" the record. Paranoia does this by setting a `deleted_at` field to the current time when you `destroy` a record, and hides it by scoping all queries on your model to only include records which do not have a `deleted_at` field. +If you wish to actually destroy an object you may call `really_destroy!`. **WARNING**: This will also *really destroy* all `dependent: destroy` records, so please aim this method away from face when using.** + +If a record has `has_many` associations defined AND those associations have `dependent: :destroy` set on them, then they will also be soft-deleted if ``acts_as_paranoid`` is set, otherwise the normal destroy will be called. + ## Installation & Usage -Put this in your Gemfile: +For Rails 3, please use version 1 of Paranoia: + +```ruby +gem 'paranoia', '~> 1.0' +``` + +For Rails 4, please use version 2 of Paranoia: + +```ruby +gem 'paranoia', '~> 2.0' +``` + +Of course you can install this from GitHub as well: ```ruby -gem 'paranoia' +gem 'paranoia', :github => 'radar/paranoia', :branch => 'rails3' +# or +gem 'paranoia', :github => 'radar/paranoia', :branch => 'rails4' ``` Then run: @@ -25,7 +43,7 @@ Updating is as simple as `bundle update paranoia`. Run: ```shell -rails generate migration AddDeletedAtToClients deleted_at:datetime +rails generate migration AddDeletedAtToClients deleted_at:datetime:index ``` and now you have a migration @@ -34,6 +52,7 @@ and now you have a migration class AddDeletedAtToClients < ActiveRecord::Migration def change add_column :clients, :deleted_at, :datetime + add_index :clients, :deleted_at end end ``` @@ -50,9 +69,23 @@ class Client < ActiveRecord::Base end ``` -Hey presto, it's there! +Hey presto, it's there! Calling `destroy` will now set the `deleted_at` column: + + +``` +>> client.deleted_at => nil +>> client.destroy => client +>> client.deleted_at => [current timestamp] +``` -If you want a method to be called on destroy, simply provide a _before\_destroy_ callback: +If you really want it gone *gone*, call `destroy!` + +``` +>> client.deleted_at => nil +>> client.destroy! => client +``` + +If you want a method to be called on destroy, simply provide a `before_destroy` callback: ```ruby class Client < ActiveRecord::Base @@ -68,6 +101,70 @@ class Client < ActiveRecord::Base end ``` +If you want to use a column other than `deleted_at`, you can pass it as an option: + +```ruby +class Client < ActiveRecord::Base + acts_as_paranoid column: :destroyed_at + + ... +end +``` + +If you want to access soft-deleted associations, override the getter method: + +```ruby +def product + Product.unscoped { super } +end +``` + +If you want to find all records, even those which are deleted: + +```ruby +Client.with_deleted +``` + +If you want to find only the deleted records: + +```ruby +Client.only_deleted +``` + +If you want to check if a record is soft-deleted: + +```ruby +client.destroyed? +``` + +If you want to restore a record: + +```ruby +Client.restore(id) +``` + +If you want to restore a whole bunch of records: + +```ruby +Client.restore([id1, id2, ..., idN]) +``` + +If you want to restore a record and their dependently destroyed associated records: + +```ruby +Client.restore(id, :recursive => true) +``` + +If you want callbacks to trigger before a restore: + +```ruby +before_restore :callback_name_goes_here +``` + +For more information, please look at the tests. + +## Acts As Paranoid Migration + You can replace the older acts_as_paranoid methods as follows: | Old Syntax | New Syntax | diff --git a/lib/paranoia.rb b/lib/paranoia.rb index 19cca058..d64d8774 100644 --- a/lib/paranoia.rb +++ b/lib/paranoia.rb @@ -1,56 +1,174 @@ +require 'active_record' unless defined? ActiveRecord + module Paranoia def self.included(klazz) klazz.extend Query + klazz.extend Callbacks end module Query - def paranoid? ; true ; end - - def only_deleted - scoped.tap { |x| x.default_scoped = false }.where("#{self.table_name}.deleted_at is not null") + def paranoid? + true end + def with_deleted scoped.tap { |x| x.default_scoped = false } end + + def only_deleted + with_deleted.where("#{self.table_name}.#{paranoia_column} IS NOT NULL") + end + alias :deleted :only_deleted + + def restore(id, opts = {}) + if id.is_a?(Array) + id.map { |one_id| restore(one_id, opts) } + else + only_deleted.find(id).restore!(opts) + end + end + end + + module Callbacks + def self.extended(klazz) + [:restore, :really_destroy].each do |callback_name| + klazz.define_callbacks callback_name + + klazz.define_singleton_method("before_#{callback_name}") do |*args, &block| + set_callback(callback_name, :before, *args, &block) + end + + klazz.define_singleton_method("around_#{callback_name}") do |*args, &block| + set_callback(callback_name, :around, *args, &block) + end + + klazz.define_singleton_method("after_#{callback_name}") do |*args, &block| + set_callback(callback_name, :after, *args, &block) + end + end + end end def destroy - run_callbacks(:destroy) { delete } + callbacks_result = run_callbacks(:destroy) { touch_paranoia_column(true) } + callbacks_result ? self : false end def delete - return if new_record? or destroyed? - update_attribute_or_column :deleted_at, Time.now + return if new_record? + touch_paranoia_column(false) end - def restore! - update_attribute_or_column :deleted_at, nil + def restore!(opts = {}) + ActiveRecord::Base.transaction do + run_callbacks(:restore) do + self.class.unscoped do + update_column paranoia_column, nil + restore_associated_records if opts[:recursive] + end + end + end end + alias :restore :restore! def destroyed? - !self.deleted_at.nil? + !!send(paranoia_column) end + alias :deleted? :destroyed? private - # Rails 3.1 adds update_column. Rails > 3.2.6 deprecates update_attribute, gone in Rails 4. - def update_attribute_or_column(*args) - respond_to?(:update_column) ? update_column(*args) : update_attribute(*args) + # touch paranoia column. + # insert time to paranoia column. + # @param with_transaction [Boolean] exec with ActiveRecord Transactions. + def touch_paranoia_column(with_transaction=false) + # This method is (potentially) called from really_destroy + # The object the method is being called on may be frozen + # Let's not touch it if it's frozen. + unless self.frozen? + if with_transaction + with_transaction_returning_status { touch(paranoia_column) } + else + touch(paranoia_column) + end + end + end + + # restore associated records that have been soft deleted when + # we called #destroy + def restore_associated_records + destroyed_associations = self.class.reflect_on_all_associations.select do |association| + association.options[:dependent] == :destroy + end + + destroyed_associations.each do |association| + association_data = send(association.name) + + unless association_data.nil? + if association_data.paranoid? + if association.collection? + association_data.only_deleted.each { |record| record.restore(:recursive => true) } + else + association_data.restore(:recursive => true) + end + end + end + end end end class ActiveRecord::Base - def self.acts_as_paranoid - alias :destroy! :destroy - alias :delete! :delete + def self.acts_as_paranoid(options={}) + alias :really_destroyed? :destroyed? + alias :ar_destroy :destroy + alias :destroy! :ar_destroy + alias :delete! :delete + def really_destroy! + transaction do + run_callbacks(:really_destroy) do + dependent_reflections = self.reflections.select do |name, reflection| + reflection.options[:dependent] == :destroy + end + if dependent_reflections.any? + dependent_reflections.each do |name, reflection| + associated_records = self.send(name) + # Paranoid models will have this method, non-paranoid models will not + next unless associated_records && associated_records.paranoid? + if reflection.collection? + associated_records.with_deleted.each(&:really_destroy!) + next + end + associated_records.really_destroy! + end + end + destroy! + end + end + end + include Paranoia - default_scope { where(:deleted_at => nil) } + class_attribute :paranoia_column + + self.paranoia_column = options[:column] || :deleted_at + default_scope { where(self.quoted_table_name + ".#{paranoia_column} IS NULL") } + + before_restore { + self.class.notify_observers(:before_restore, self) if self.class.respond_to?(:notify_observers) + } + after_restore { + self.class.notify_observers(:after_restore, self) if self.class.respond_to?(:notify_observers) + } end - def self.paranoid? ; false ; end - def paranoid? ; self.class.paranoid? ; end + def self.paranoid? + false + end + + def paranoid? + self.class.paranoid? + end # Override the persisted method to allow for the paranoia gem. # If a paranoid record is selected, then we only want to check @@ -58,4 +176,29 @@ def paranoid? ; self.class.paranoid? ; end def persisted? paranoid? ? !new_record? : super end + + private + + def paranoia_column + self.class.paranoia_column + end +end + + +require 'paranoia/rspec' if defined? RSpec + +module ActiveRecord + module Validations + class UniquenessValidator < ActiveModel::EachValidator + protected + def build_relation_with_paranoia(klass, table, attribute, value) + relation = build_relation_without_paranoia(klass, table, attribute, value) + return relation unless klass.respond_to?(:paranoia_column) + relation.and(klass.arel_table[klass.paranoia_column].eq(nil)) + end + alias_method_chain :build_relation, :paranoia + end + end end + +ActiveRecord::Callbacks::CALLBACKS.push(:before_restore, :after_restore, :before_really_destroy, :after_really_destroy) diff --git a/lib/paranoia/rspec.rb b/lib/paranoia/rspec.rb new file mode 100644 index 00000000..578de759 --- /dev/null +++ b/lib/paranoia/rspec.rb @@ -0,0 +1,13 @@ +require 'rspec/expectations' + +# Validate the subject's class did call "acts_as_paranoid" +RSpec::Matchers.define :act_as_paranoid do + match { |subject| subject.class.ancestors.include?(Paranoia) } + + failure_message { "expected #{subject.class} to use `acts_as_paranoid`" } + failure_message_when_negated { "expected #{subject.class} not to use `acts_as_paranoid`" } + + # RSpec 2 compatibility: + alias_method :failure_message_for_should, :failure_message + alias_method :failure_message_for_should_not, :failure_message_when_negated +end diff --git a/lib/paranoia/version.rb b/lib/paranoia/version.rb index 19fc57a4..5d02e025 100644 --- a/lib/paranoia/version.rb +++ b/lib/paranoia/version.rb @@ -1,3 +1,3 @@ module Paranoia - VERSION = "1.2.0" + VERSION = '1.3.4' end diff --git a/paranoia.gemspec b/paranoia.gemspec index bc26477b..8ec10e66 100644 --- a/paranoia.gemspec +++ b/paranoia.gemspec @@ -1,26 +1,26 @@ # -*- encoding: utf-8 -*- -require File.expand_path("../lib/paranoia/version", __FILE__) +require File.expand_path('../lib/paranoia/version', __FILE__) Gem::Specification.new do |s| - s.name = "paranoia" - s.version = Paranoia::VERSION - s.platform = Gem::Platform::RUBY - s.authors = ["radarlistener@gmail.com"] - s.email = [] - s.homepage = "http://rubygems.org/gems/paranoia" - s.summary = "Paranoia is a re-implementation of acts_as_paranoid for Rails 3, using much, much, much less code." + s.name = 'paranoia' + s.version = Paranoia::VERSION + s.platform = Gem::Platform::RUBY + s.authors = %w(radarlistener@gmail.com) + s.email = [] + s.homepage = 'http://rubygems.org/gems/paranoia' + s.summary = 'Paranoia is a re-implementation of acts_as_paranoid for Rails 3, using much, much, much less code.' s.description = "Paranoia is a re-implementation of acts_as_paranoid for Rails 3, using much, much, much less code. You would use either plugin / gem if you wished that when you called destroy on an Active Record object that it didn't actually destroy it, but just \"hid\" the record. Paranoia does this by setting a deleted_at field to the current time when you destroy a record, and hides it by scoping all queries on your model to only include records which do not have a deleted_at field." - s.required_rubygems_version = ">= 1.3.6" - s.rubyforge_project = "paranoia" - - s.add_dependency "activerecord", ">= 3.1.0" + s.required_rubygems_version = '>= 1.3.6' + s.rubyforge_project = 'paranoia' - s.add_development_dependency "bundler", ">= 1.0.0" - s.add_development_dependency "sqlite3" - s.add_development_dependency "rake", "0.8.7" - - s.files = `git ls-files`.split("\n") - s.executables = `git ls-files`.split("\n").map{|f| f =~ /^bin\/(.*)/ ? $1 : nil}.compact + s.add_dependency 'activerecord', '~> 3.1' + + s.add_development_dependency 'bundler', '>= 1.0.0' + s.add_development_dependency 'rake' + s.add_development_dependency 'test-unit' + + s.files = `git ls-files`.split("\n") + s.executables = `git ls-files`.split("\n").map { |f| f =~ /^bin\/(.*)/ ? $1 : nil }.compact s.require_path = 'lib' end diff --git a/test/paranoia_test.rb b/test/paranoia_test.rb index 9e763ea2..4450ae28 100644 --- a/test/paranoia_test.rb +++ b/test/paranoia_test.rb @@ -1,6 +1,6 @@ require 'test/unit' require 'active_record' -require File.expand_path(File.dirname(__FILE__) + "/../lib/paranoia") +require File.expand_path(File.dirname(__FILE__) + '/../lib/paranoia') DB_FILE = 'tmp/test_db' @@ -10,13 +10,17 @@ ActiveRecord::Base.establish_connection :adapter => 'sqlite3', :database => DB_FILE ActiveRecord::Base.connection.execute 'CREATE TABLE parent_models (id INTEGER NOT NULL PRIMARY KEY, deleted_at DATETIME)' ActiveRecord::Base.connection.execute 'CREATE TABLE paranoid_models (id INTEGER NOT NULL PRIMARY KEY, parent_model_id INTEGER, deleted_at DATETIME)' +ActiveRecord::Base.connection.execute 'CREATE TABLE paranoid_model_with_belongs (id INTEGER NOT NULL PRIMARY KEY, parent_model_id INTEGER, deleted_at DATETIME, paranoid_model_with_has_one_id INTEGER)' ActiveRecord::Base.connection.execute 'CREATE TABLE featureful_models (id INTEGER NOT NULL PRIMARY KEY, deleted_at DATETIME, name VARCHAR(32))' ActiveRecord::Base.connection.execute 'CREATE TABLE plain_models (id INTEGER NOT NULL PRIMARY KEY, deleted_at DATETIME)' ActiveRecord::Base.connection.execute 'CREATE TABLE callback_models (id INTEGER NOT NULL PRIMARY KEY, deleted_at DATETIME)' +ActiveRecord::Base.connection.execute 'CREATE TABLE fail_callback_models (id INTEGER NOT NULL PRIMARY KEY, deleted_at DATETIME)' ActiveRecord::Base.connection.execute 'CREATE TABLE related_models (id INTEGER NOT NULL PRIMARY KEY, parent_model_id INTEGER NOT NULL, deleted_at DATETIME)' -ActiveRecord::Base.connection.execute 'CREATE TABLE employers (id INTEGER NOT NULL PRIMARY KEY, deleted_at DATETIME)' +ActiveRecord::Base.connection.execute 'CREATE TABLE employers (id INTEGER NOT NULL PRIMARY KEY, deleted_at DATETIME, name VARCHAR(10))' ActiveRecord::Base.connection.execute 'CREATE TABLE employees (id INTEGER NOT NULL PRIMARY KEY, deleted_at DATETIME)' ActiveRecord::Base.connection.execute 'CREATE TABLE jobs (id INTEGER NOT NULL PRIMARY KEY, employer_id INTEGER NOT NULL, employee_id INTEGER NOT NULL, deleted_at DATETIME)' +ActiveRecord::Base.connection.execute 'CREATE TABLE custom_column_models (id INTEGER NOT NULL PRIMARY KEY, destroyed_at DATETIME)' +ActiveRecord::Base.connection.execute 'CREATE TABLE non_paranoid_models (id INTEGER NOT NULL PRIMARY KEY, parent_model_id INTEGER)' class ParanoiaTest < Test::Unit::TestCase def test_plain_model_class_is_not_paranoid @@ -59,6 +63,42 @@ def test_destroy_behavior_for_plain_models assert_equal 0, model.class.unscoped.count end + # Anti-regression test for #81, which would've introduced a bug to break this test. + def test_destroy_behavior_for_plain_models_callbacks + model = CallbackModel.new + model.save + model.remove_called_variables # clear called callback flags + model.destroy + + assert_equal nil, model.instance_variable_get(:@update_callback_called) + assert_equal nil, model.instance_variable_get(:@save_callback_called) + assert_equal nil, model.instance_variable_get(:@validate_called) + assert_equal nil, model.instance_variable_get(:@before_really_destroy_called) + assert_equal nil, model.instance_variable_get(:@really_destroy_called) + assert_equal nil, model.instance_variable_get(:@after_really_destroy_called) + + assert model.instance_variable_get(:@destroy_callback_called) + assert model.instance_variable_get(:@after_destroy_callback_called) + assert model.instance_variable_get(:@after_commit_callback_called) + end + + + def test_delete_behavior_for_plain_models_callbacks + model = CallbackModel.new + model.save + model.remove_called_variables # clear called callback flags + model.delete + + assert_equal nil, model.instance_variable_get(:@update_callback_called) + assert_equal nil, model.instance_variable_get(:@save_callback_called) + assert_equal nil, model.instance_variable_get(:@validate_called) + assert_equal nil, model.instance_variable_get(:@destroy_callback_called) + assert_equal nil, model.instance_variable_get(:@after_destroy_callback_called) + assert_equal nil, model.instance_variable_get(:@after_commit_callback_called) + assert_equal nil, model.instance_variable_get(:@after_really_destroy_called) + assert_equal nil, model.instance_variable_get(:@really_destroy_called) + end + def test_destroy_behavior_for_paranoid_models model = ParanoidModel.new assert_equal 0, model.class.count @@ -67,11 +107,12 @@ def test_destroy_behavior_for_paranoid_models model.destroy assert_equal false, model.deleted_at.nil? + assert_equal false, model.really_destroyed? assert_equal 0, model.class.count assert_equal 1, model.class.unscoped.count end - + def test_scoping_behavior_for_paranoid_models ParanoidModel.unscoped.delete_all parent1 = ParentModel.create @@ -82,9 +123,27 @@ def test_scoping_behavior_for_paranoid_models p2.destroy assert_equal 0, parent1.paranoid_models.count assert_equal 1, parent1.paranoid_models.only_deleted.count + assert_equal 1, parent1.paranoid_models.deleted.count p3 = ParanoidModel.create(:parent_model => parent1) assert_equal 2, parent1.paranoid_models.with_deleted.count - assert_equal [p1,p3], parent1.paranoid_models.with_deleted + assert_equal [p1, p3], parent1.paranoid_models.with_deleted + end + + def test_destroy_behavior_for_custom_column_models + model = CustomColumnModel.new + assert_equal 0, model.class.count + model.save! + assert_nil model.destroyed_at + assert_equal 1, model.class.count + model.destroy + + assert_equal false, model.destroyed_at.nil? + assert model.destroyed? + + assert_equal 0, model.class.count + assert_equal 1, model.class.unscoped.count + assert_equal 1, model.class.only_deleted.count + assert_equal 1, model.class.deleted.count end def test_destroy_behavior_for_featureful_paranoid_models @@ -102,8 +161,8 @@ def test_destroy_behavior_for_featureful_paranoid_models # Regression test for #24 def test_chaining_for_paranoid_models - scope = FeaturefulModel.where(:name => "foo").only_deleted - assert_equal "foo", scope.where_values_hash[:name] + scope = FeaturefulModel.where(:name => 'foo').only_deleted + assert_equal 'foo', scope.where_values_hash['name'] assert_equal 2, scope.where_values.count end @@ -116,6 +175,7 @@ def test_only_destroyed_scope_for_paranoid_models assert_equal model, ParanoidModel.only_deleted.last assert_equal false, ParanoidModel.only_deleted.include?(model2) + assert_equal false, ParanoidModel.deleted.include?(model2) end def test_default_scope_for_has_many_relationships @@ -163,14 +223,14 @@ def test_delete_behavior_for_callbacks model = CallbackModel.new model.save model.delete - assert_equal nil, model.instance_variable_get(:@callback_called) + assert_equal nil, model.instance_variable_get(:@destroy_callback_called) end def test_destroy_behavior_for_callbacks model = CallbackModel.new model.save model.destroy - assert model.instance_variable_get(:@callback_called) + assert model.instance_variable_get(:@destroy_callback_called) end def test_restore @@ -188,25 +248,244 @@ def test_restore assert_equal false, model.destroyed? end - def test_real_destroy + # Regression test for #92 + def test_destroy_twice + model = ParanoidModel.new + model.save + model.destroy + model.destroy + + assert_equal 1, ParanoidModel.unscoped.where(id: model.id).count + end + + def test_destroy_return_value_on_success + model = ParanoidModel.create + return_value = model.destroy + + assert_equal(return_value, model) + end + + def test_destroy_return_value_on_failure + model = FailCallbackModel.create + return_value = model.destroy + + assert_equal(return_value, false) + end + + def test_restore_behavior_for_callbacks + model = CallbackModel.new + model.save + id = model.id + model.destroy + + assert model.destroyed? + + model = CallbackModel.only_deleted.find(id) + model.restore! + model.reload + + assert model.instance_variable_get(:@restore_callback_called) + end + + def test_really_destroy + model = ParanoidModel.new + model.save + model.destroy! + + assert_equal 0, ParanoidModel.unscoped.where(id: model.id).count + end + + def test_really_destroyed model = ParanoidModel.new model.save model.destroy! - assert_equal false, ParanoidModel.unscoped.exists?(model.id) + assert model.really_destroyed? + end + + def test_really_destroy_with_callback + model = CallbackModel.new + model.save + model.remove_called_variables + + model.really_destroy! + + assert model.instance_variable_get(:@destroy_callback_called) + assert model.instance_variable_get(:@after_destroy_callback_called) + + assert model.instance_variable_get(:@really_destroy_called) + assert model.instance_variable_get(:@after_really_destroy_called) + + refute CallbackModel.unscoped.exists?(model.id) + end + + def test_real_destroy_dependent_destroy + parent = ParentModel.create + child = parent.very_related_models.create + parent.really_destroy! + refute RelatedModel.unscoped.exists?(child.id) + end + + def test_real_destroy_dependent_destroy_after_normal_destroy + parent = ParentModel.create + child = parent.very_related_models.create + parent.destroy + parent.really_destroy! + refute RelatedModel.unscoped.exists?(child.id) + end + + def test_real_destroy_dependent_destroy_after_normal_destroy_does_not_delete_other_children + parent_1 = ParentModel.create + child_1 = parent_1.very_related_models.create + + parent_2 = ParentModel.create + child_2 = parent_2.very_related_models.create + parent_1.destroy + parent_1.really_destroy! + assert RelatedModel.unscoped.exists?(child_2.id) end def test_real_delete model = ParanoidModel.new model.save model.delete! + assert_equal 0, ParanoidModel.unscoped.where(id: model.id).count + end + + def test_multiple_restore + a = ParanoidModel.new + a.save + a_id = a.id + a.destroy + + b = ParanoidModel.new + b.save + b_id = b.id + b.destroy + + c = ParanoidModel.new + c.save + c_id = c.id + c.destroy + + ParanoidModel.restore([a_id, c_id]) + + a.reload + b.reload + c.reload + + refute a.destroyed? + assert b.destroyed? + refute c.destroyed? + end + + def test_restore_with_associations + parent = ParentModel.create + first_child = parent.very_related_models.create + second_child = parent.non_paranoid_models.create + + parent.destroy + assert_equal false, parent.deleted_at.nil? + assert_equal false, first_child.reload.deleted_at.nil? + assert_equal true, second_child.destroyed? + + parent.restore! + assert_equal true, parent.deleted_at.nil? + assert_equal false, first_child.reload.deleted_at.nil? + assert_equal true, second_child.destroyed? + + parent.destroy + parent.restore(:recursive => true) + assert_equal true, parent.deleted_at.nil? + assert_equal true, first_child.reload.deleted_at.nil? + assert_equal true, second_child.destroyed? + + parent.destroy + ParentModel.restore(parent.id, :recursive => true) + assert_equal true, parent.reload.deleted_at.nil? + assert_equal true, first_child.reload.deleted_at.nil? + assert_equal true, second_child.destroyed? + end + + # regression tests for #118 + def test_restore_with_has_one_association + # setup and destroy test objects + hasOne = ParanoidModelWithHasOne.create + belongsTo = ParanoidModelWithBelong.create + hasOne.paranoid_model_with_belong = belongsTo + hasOne.save! + + hasOne.destroy + assert_equal false, hasOne.deleted_at.nil? + assert_equal false, belongsTo.deleted_at.nil? + + # Does it restore has_one associations? + hasOne.restore(:recursive => true) + hasOne.save! + + assert_equal true, hasOne.reload.deleted_at.nil? + assert_equal true, belongsTo.reload.deleted_at.nil?, "#{belongsTo.deleted_at}" + assert ParanoidModelWithBelong.with_deleted.reload.count != 0, "There should be a record" + end + + def test_restore_with_nil_has_one_association + # setup and destroy test object + hasOne = ParanoidModelWithHasOne.create + hasOne.destroy + assert_equal false, hasOne.reload.deleted_at.nil? + + # Does it raise NoMethodException on restore of nil + hasOne.restore(:recursive => true) + + assert hasOne.reload.deleted_at.nil? + end + + def test_has_one_really_destroy_with_nil + model = ParanoidModelWithHasOne.create + model.really_destroy! + + refute ParanoidModelWithBelong.unscoped.exists?(model.id) + end + + def test_has_one_really_destroy_with_record + model = ParanoidModelWithHasOne.create { |record| record.build_paranoid_model_with_belong } + model.really_destroy! + + refute ParanoidModelWithBelong.unscoped.exists?(model.id) + end + + def test_observers_notified + a = ParanoidModelWithObservers.create + a.destroy + a.restore! - assert_equal false, ParanoidModel.unscoped.exists?(model.id) + assert a.observers_notified.select {|args| args == [:before_restore, a]} + assert a.observers_notified.select {|args| args == [:after_restore, a]} + end + + def test_observers_not_notified_if_not_supported + a = ParanoidModelWithObservers.create + a.destroy + a.restore! + # essentially, we're just ensuring that this doesn't crash + end + + def test_validates_uniqueness_only_checks_non_deleted_records + a = Employer.create!(name: "A") + a.destroy + b = Employer.new(name: "A") + assert b.valid? + end + + def test_validates_uniqueness_still_works_on_non_deleted_records + a = Employer.create!(name: "A") + b = Employer.new(name: "A") + refute b.valid? end private def get_featureful_model - FeaturefulModel.new(:name => "not empty") + FeaturefulModel.new(:name => 'not empty') end end @@ -221,6 +500,13 @@ class ParanoidModel < ActiveRecord::Base acts_as_paranoid end +class FailCallbackModel < ActiveRecord::Base + belongs_to :parent_model + acts_as_paranoid + + before_destroy { |_| false } +end + class FeaturefulModel < ActiveRecord::Base acts_as_paranoid validates :name, :presence => true, :uniqueness => true @@ -231,12 +517,29 @@ class PlainModel < ActiveRecord::Base class CallbackModel < ActiveRecord::Base acts_as_paranoid - before_destroy {|model| model.instance_variable_set :@callback_called, true } + before_destroy {|model| model.instance_variable_set :@destroy_callback_called, true } + before_restore {|model| model.instance_variable_set :@restore_callback_called, true } + before_update {|model| model.instance_variable_set :@update_callback_called, true } + before_save {|model| model.instance_variable_set :@save_callback_called, true} + + after_destroy {|model| model.instance_variable_set :@after_destroy_callback_called, true } + after_commit {|model| model.instance_variable_set :@after_commit_callback_called, true } + + validate {|model| model.instance_variable_set :@validate_called, true } + + before_really_destroy { |model| model.instance_variable_set :@really_destroy_called, true } + after_really_destroy { |model| model.instance_variable_set :@after_really_destroy_called, true } + + def remove_called_variables + instance_variables.each {|name| (name.to_s.end_with?('_called')) ? remove_instance_variable(name) : nil} + end end class ParentModel < ActiveRecord::Base acts_as_paranoid has_many :related_models + has_many :very_related_models, :class_name => 'RelatedModel', dependent: :destroy + has_many :non_paranoid_models, dependent: :destroy end class RelatedModel < ActiveRecord::Base @@ -246,6 +549,7 @@ class RelatedModel < ActiveRecord::Base class Employer < ActiveRecord::Base acts_as_paranoid + validates_uniqueness_of :name has_many :jobs has_many :employees, :through => :jobs end @@ -261,3 +565,34 @@ class Job < ActiveRecord::Base belongs_to :employer belongs_to :employee end + +class CustomColumnModel < ActiveRecord::Base + acts_as_paranoid column: :destroyed_at +end + +class NonParanoidModel < ActiveRecord::Base +end + +class ParanoidModelWithObservers < ParanoidModel + def observers_notified + @observers_notified ||= [] + end + + def self.notify_observer(*args) + observers_notified << args + end +end + +class ParanoidModelWithoutObservers < ParanoidModel + self.class.send(remove_method :notify_observers) if method_defined?(:notify_observers) +end + +# refer back to regression test for #118 +class ParanoidModelWithHasOne < ParanoidModel + has_one :paranoid_model_with_belong, :dependent => :destroy +end + +class ParanoidModelWithBelong < ActiveRecord::Base + acts_as_paranoid + belongs_to :paranoid_model_with_has_one +end