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
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:
......
......@@ -29,7 +29,16 @@ module Paranoia
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 @@ module Paranoia
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 @@ module Paranoia
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 @@ module Paranoia
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
......
......@@ -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
......@@ -126,6 +127,22 @@ class ParanoiaTest < test_framework
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)
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)
end
......@@ -185,6 +202,25 @@ class ParanoiaTest < test_framework
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
......
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