From 45155254e2aefc7dd62949b3f70343e58dc543e7 Mon Sep 17 00:00:00 2001 From: Ben Woosley Date: Tue, 16 Jun 2015 01:01:23 -0700 Subject: [PATCH 1/2] Inline touch_paranoia_column into delete --- lib/paranoia.rb | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/lib/paranoia.rb b/lib/paranoia.rb index a89ad876..ba70c7e9 100644 --- a/lib/paranoia.rb +++ b/lib/paranoia.rb @@ -68,7 +68,7 @@ def self.extended(klazz) def destroy transaction do run_callbacks(:destroy) do - result = touch_paranoia_column + result = delete if result && ActiveRecord::VERSION::STRING >= '4.2' each_counter_cached_associations do |association| foreign_key = association.reflection.foreign_key.to_sym @@ -85,7 +85,13 @@ def destroy end def delete - touch_paranoia_column + raise ActiveRecord::ReadOnlyRecord, "#{self.class} is marked as readonly" if readonly? + if persisted? + touch(paranoia_column) + elsif !frozen? + write_attribute(paranoia_column, current_time_from_proper_timezone) + end + self end def restore!(opts = {}) @@ -113,18 +119,6 @@ def paranoia_destroyed? private - # touch paranoia column. - # insert time to paranoia column. - def touch_paranoia_column - raise ActiveRecord::ReadOnlyRecord, "#{self.class} is marked as readonly" if readonly? - if persisted? - touch(paranoia_column) - elsif !frozen? - write_attribute(paranoia_column, current_time_from_proper_timezone) - end - self - end - # restore associated records that have been soft deleted when # we called #destroy def restore_associated_records From b2b8d19e62bb633aac24ae5071e81e2230e47396 Mon Sep 17 00:00:00 2001 From: Ben Woosley Date: Tue, 16 Jun 2015 01:11:54 -0700 Subject: [PATCH 2/2] Add paranoia_destroy_attributes and paranoia_restore_attributes as extension points Use update_columns rather than touch to update the record, for generality Unlike touch, update_columns does not create a transaction for itself, so we need to add the record to the transaction, if present. If there is not a current_transaction, the add is a no-op. This all means that delete will not invoke a transaction or run the after_commit callbacks unless called from within one, which is consistent with the Rails docs and the behavior of ActiveRecord::Base#delete. --- README.md | 45 +++++++++++++++++++++++++++++++++- lib/paranoia.rb | 32 +++++++++++++++++++++---- test/paranoia_test.rb | 56 ++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 127 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index ebf8275f..a9e5a46d 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ If you wish to actually destroy an object you may call `really_destroy!`. **WARN 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. ## Getting Started Video -Setup and basic usage of the paranoia gem +Setup and basic usage of the paranoia gem [GoRails #41](https://gorails.com/episodes/soft-delete-with-paranoia) ## Installation & Usage @@ -185,6 +185,49 @@ add_index :clients, [:group_id, :other_id], where: "deleted_at IS NULL" Of course, this is not necessary for the indexes you always use in association with `with_deleted` or `only_deleted`. +##### Unique Indexes + +Becuse NULL != NULL in standard SQL, we can not simply create a unique index +on the deleted_at column and expect it to enforce that there only be one record +with a certain combination of values. + +If your database supports them, good alternatives include partial indexes +(above) and indexes on computed columns. E.g. + +``` ruby +add_index :clients, [:group_id, 'COALESCE(deleted_at, false)'], unique: true +``` + +If not, an alternative is to create a separate column which is maintained +alongside deleted_at for the sake of enforcing uniqueness. To that end, +paranoia makes use of two method to make its destroy and restore actions: +paranoia_restore_attributes and paranoia_destroy_attributes. + +``` ruby +add_column :clients, :active, :boolean +add_index :clients, [:group_id, :active], unique: true + +class Client < ActiveRecord::Base + # optionally have paranoia make use of your unique column, so that + # your lookups will benefit from the unique index + acts_as_paranoid column: :active, sentinel_value: true + + def paranoia_restore_attributes + { + deleted_at: nil, + active: true + } + end + + def paranoia_destroy_attributes + { + deleted_at: current_time_from_proper_timezone, + active: nil + } + end +end +``` + ## Acts As Paranoid Migration You can replace the older `acts_as_paranoid` methods as follows: diff --git a/lib/paranoia.rb b/lib/paranoia.rb index ba70c7e9..2b4c02f9 100644 --- a/lib/paranoia.rb +++ b/lib/paranoia.rb @@ -29,7 +29,16 @@ def with_deleted end def only_deleted - with_deleted.where.not(paranoia_column => paranoia_sentinel_value) + if paranoia_sentinel_value.nil? + with_deleted.where.not(paranoia_column => paranoia_sentinel_value) + else + # if paranoia_sentinel_value is not null, then it is possible that + # some deleted rows will hold a null value in the paranoia column + # these will not match != sentinel value because "NULL != value" is + # NULL under the sql standard + quoted_paranoia_column = connection.quote_column_name(paranoia_column) + with_deleted.where("#{quoted_paranoia_column} IS NULL OR #{quoted_paranoia_column} != ?", paranoia_sentinel_value) + end end alias :deleted :only_deleted @@ -87,9 +96,12 @@ def destroy def delete raise ActiveRecord::ReadOnlyRecord, "#{self.class} is marked as readonly" if readonly? if persisted? - touch(paranoia_column) + # if a transaction exists, add the record so that after_commit + # callbacks can be run + add_to_transaction + update_columns(paranoia_destroy_attributes) elsif !frozen? - write_attribute(paranoia_column, current_time_from_proper_timezone) + assign_attributes(paranoia_destroy_attributes) end self end @@ -102,7 +114,7 @@ def restore!(opts = {}) noop_if_frozen = ActiveRecord.version < Gem::Version.new("4.1") if (noop_if_frozen && !@attributes.frozen?) || !noop_if_frozen write_attribute paranoia_column, paranoia_sentinel_value - update_column paranoia_column, paranoia_sentinel_value + update_columns(paranoia_restore_attributes) end restore_associated_records if opts[:recursive] end @@ -119,6 +131,18 @@ def paranoia_destroyed? private + def paranoia_restore_attributes + { + paranoia_column => paranoia_sentinel_value + } + end + + def paranoia_destroy_attributes + { + paranoia_column => current_time_from_proper_timezone + } + end + # restore associated records that have been soft deleted when # we called #destroy def restore_associated_records diff --git a/test/paranoia_test.rb b/test/paranoia_test.rb index b2314644..fe256015 100644 --- a/test/paranoia_test.rb +++ b/test/paranoia_test.rb @@ -37,7 +37,8 @@ def setup! 'polymorphic_models' => 'parent_id INTEGER, parent_type STRING, deleted_at DATETIME', 'namespaced_paranoid_has_ones' => 'deleted_at DATETIME, paranoid_belongs_tos_id INTEGER', 'namespaced_paranoid_belongs_tos' => 'deleted_at DATETIME, paranoid_has_one_id INTEGER', - 'unparanoid_unique_models' => 'name VARCHAR(32), paranoid_with_unparanoids_id INTEGER' + 'unparanoid_unique_models' => 'name VARCHAR(32), paranoid_with_unparanoids_id INTEGER', + 'active_column_models' => 'deleted_at DATETIME, active BOOLEAN' }.each do |table_name, columns_as_sql_string| ActiveRecord::Base.connection.execute "CREATE TABLE #{table_name} (id INTEGER NOT NULL PRIMARY KEY, #{columns_as_sql_string})" end @@ -121,6 +122,22 @@ def test_delete_behavior_for_plain_models_callbacks 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) + end + + def test_delete_in_transaction_behavior_for_plain_models_callbacks + model = CallbackModel.new + model.save + model.remove_called_variables # clear called callback flags + CallbackModel.transaction do + model.delete + end + 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) @@ -185,6 +202,25 @@ def test_default_sentinel_value assert_equal nil, ParanoidModel.paranoia_sentinel_value end + def test_active_column_model + model = ActiveColumnModel.new + assert_equal 0, model.class.count + model.save! + assert_nil model.deleted_at + assert_equal true, model.active + assert_equal 1, model.class.count + model.destroy + + assert_equal false, model.deleted_at.nil? + assert_nil model.active + assert model.paranoia_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_sentinel_value_for_custom_sentinel_models model = CustomSentinelModel.new assert_equal 0, model.class.count @@ -978,6 +1014,24 @@ class CustomSentinelModel < ActiveRecord::Base acts_as_paranoid sentinel_value: DateTime.new(0) end +class ActiveColumnModel < ActiveRecord::Base + acts_as_paranoid column: :active, sentinel_value: true + + def paranoia_restore_attributes + { + deleted_at: nil, + active: true + } + end + + def paranoia_destroy_attributes + { + deleted_at: current_time_from_proper_timezone, + active: nil + } + end +end + class NonParanoidModel < ActiveRecord::Base end