Commit b2b8d19e by Ben Woosley

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.
parent 45155254
...@@ -9,7 +9,7 @@ If you wish to actually destroy an object you may call `really_destroy!`. **WARN ...@@ -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. 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 ## 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) [GoRails #41](https://gorails.com/episodes/soft-delete-with-paranoia)
## Installation & Usage ## Installation & Usage
...@@ -185,6 +185,49 @@ add_index :clients, [:group_id, :other_id], where: "deleted_at IS NULL" ...@@ -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`. 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 ## Acts As Paranoid Migration
You can replace the older `acts_as_paranoid` methods as follows: You can replace the older `acts_as_paranoid` methods as follows:
......
...@@ -29,7 +29,16 @@ module Paranoia ...@@ -29,7 +29,16 @@ module Paranoia
end end
def only_deleted 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 end
alias :deleted :only_deleted alias :deleted :only_deleted
...@@ -87,9 +96,12 @@ module Paranoia ...@@ -87,9 +96,12 @@ module Paranoia
def delete def delete
raise ActiveRecord::ReadOnlyRecord, "#{self.class} is marked as readonly" if readonly? raise ActiveRecord::ReadOnlyRecord, "#{self.class} is marked as readonly" if readonly?
if persisted? 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? elsif !frozen?
write_attribute(paranoia_column, current_time_from_proper_timezone) assign_attributes(paranoia_destroy_attributes)
end end
self self
end end
...@@ -102,7 +114,7 @@ module Paranoia ...@@ -102,7 +114,7 @@ module Paranoia
noop_if_frozen = ActiveRecord.version < Gem::Version.new("4.1") noop_if_frozen = ActiveRecord.version < Gem::Version.new("4.1")
if (noop_if_frozen && !@attributes.frozen?) || !noop_if_frozen if (noop_if_frozen && !@attributes.frozen?) || !noop_if_frozen
write_attribute paranoia_column, paranoia_sentinel_value write_attribute paranoia_column, paranoia_sentinel_value
update_column paranoia_column, paranoia_sentinel_value update_columns(paranoia_restore_attributes)
end end
restore_associated_records if opts[:recursive] restore_associated_records if opts[:recursive]
end end
...@@ -119,6 +131,18 @@ module Paranoia ...@@ -119,6 +131,18 @@ module Paranoia
private 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 # restore associated records that have been soft deleted when
# we called #destroy # we called #destroy
def restore_associated_records def restore_associated_records
......
...@@ -37,7 +37,8 @@ def setup! ...@@ -37,7 +37,8 @@ def setup!
'polymorphic_models' => 'parent_id INTEGER, parent_type STRING, deleted_at DATETIME', '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_has_ones' => 'deleted_at DATETIME, paranoid_belongs_tos_id INTEGER',
'namespaced_paranoid_belongs_tos' => 'deleted_at DATETIME, paranoid_has_one_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| }.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})" ActiveRecord::Base.connection.execute "CREATE TABLE #{table_name} (id INTEGER NOT NULL PRIMARY KEY, #{columns_as_sql_string})"
end end
...@@ -126,6 +127,22 @@ class ParanoiaTest < test_framework ...@@ -126,6 +127,22 @@ class ParanoiaTest < test_framework
assert_equal nil, model.instance_variable_get(:@validate_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(:@destroy_callback_called)
assert_equal nil, model.instance_variable_get(:@after_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)
assert_equal nil, model.instance_variable_get(:@destroy_callback_called)
assert_equal nil, model.instance_variable_get(:@after_destroy_callback_called)
assert model.instance_variable_get(:@after_commit_callback_called) assert model.instance_variable_get(:@after_commit_callback_called)
end end
...@@ -185,6 +202,25 @@ class ParanoiaTest < test_framework ...@@ -185,6 +202,25 @@ class ParanoiaTest < test_framework
assert_equal nil, ParanoidModel.paranoia_sentinel_value assert_equal nil, ParanoidModel.paranoia_sentinel_value
end 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 def test_sentinel_value_for_custom_sentinel_models
model = CustomSentinelModel.new model = CustomSentinelModel.new
assert_equal 0, model.class.count assert_equal 0, model.class.count
...@@ -978,6 +1014,24 @@ class CustomSentinelModel < ActiveRecord::Base ...@@ -978,6 +1014,24 @@ class CustomSentinelModel < ActiveRecord::Base
acts_as_paranoid sentinel_value: DateTime.new(0) acts_as_paranoid sentinel_value: DateTime.new(0)
end 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 class NonParanoidModel < ActiveRecord::Base
end end
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment