Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce #paranoia_destroy_attributes and #paranoia_restore_attributes extension points #245

Merged
merged 2 commits into from
Jul 14, 2015
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 44 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
46 changes: 32 additions & 14 deletions lib/paranoia.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -68,7 +77,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
Expand All @@ -85,7 +94,16 @@ def destroy
end

def delete
touch_paranoia_column
raise ActiveRecord::ReadOnlyRecord, "#{self.class} is marked as readonly" if readonly?
if persisted?
# 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?
assign_attributes(paranoia_destroy_attributes)
end
self
end

def restore!(opts = {})
Expand All @@ -96,7 +114,7 @@ def restore!(opts = {})
noop_if_frozen = ActiveRecord.version < Gem::Version.new("4.1")
if (noop_if_frozen && [email protected]?) || !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
Expand All @@ -113,16 +131,16 @@ 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
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
Expand Down
56 changes: 55 additions & 1 deletion test/paranoia_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down