Commit 566b1cbd by n3bulous Committed by Ryan Bigg

option column for flagging deletes allowing unique keys to work properly

Fixes #138
parent d6574439
......@@ -6,7 +6,7 @@ You would use either plugin / gem if you wished that when you called `destroy` o
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.
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
......@@ -75,9 +75,9 @@ Hey presto, it's there! Calling `destroy` will now set the `deleted_at` column:
``` ruby
>> client.deleted_at
# => nil
>> client.destroy
>> client.destroy
# => client
>> client.deleted_at
>> client.deleted_at
# => [current timestamp]
```
......@@ -178,6 +178,44 @@ You can replace the older `acts_as_paranoid` methods as follows:
|`find_with_deleted(:first)` | `Client.with_deleted.first` |
|`find_with_deleted(id)` | `Client.with_deleted.find(id)` |
## Support for Unique Keys with Null Values
With most databases, a unique key containing a null value will not be enforced because the null value is unique per row.
``` ruby
class AddDeletedAtToClients < ActiveRecord::Migration
def change
add_column :clients, :deleted_at, :datetime
add_index :clients, [:username, :deleted_at], unique: true
end
end
```
Given the migration above, you could have multiple users with username bob given the following inserts: ('bob', null), ('bob', null), ('bob', null). We can agree this is not the expected behavior.
To avoid this problem, we could use a flag column instead of a datetime, but the datetime value has intrinsic usefulness. Instead, we can add a second column for the unique key that always has a value, in this case 0 or 1:
``` ruby
class AddDeletedAtToClients < ActiveRecord::Migration
def change
add_column :clients, :deleted_at, :datetime
add_column :clients, :is_deleted, :boolean, null: false, default: 0
add_index :clients, [:username, :is_deleted], unique: true
end
end
```
Support this new column by updating your model as such:
``` ruby
class Client < ActiveRecord::Base
acts_as_paranoid :flag_column => :is_deleted
...
end
```
If you create an index on the flag column, and you want paranoia to use that index instead of deleted_at, you can add `:index_column => :is_deleted` to the acts_as_paranoid definition.
## License
This gem is released under the MIT license.
......@@ -11,14 +11,14 @@ module Paranoia
def with_deleted
if ActiveRecord::VERSION::STRING >= "4.1"
unscope where: paranoia_column
unscope where: paranoia_indexed_column
else
all.tap { |x| x.default_scoped = false }
end
end
def only_deleted
with_deleted.where.not(paranoia_column => nil)
with_deleted.where.not(paranoia_indexed_column => paranoia_false_value)
end
alias :deleted :only_deleted
......@@ -29,6 +29,12 @@ module Paranoia
only_deleted.find(id).restore!(opts)
end
end
private
def paranoia_false_value
(paranoia_indexed_column == paranoia_column) ? nil : 0
end
end
module Callbacks
......@@ -73,6 +79,7 @@ module Paranoia
ActiveRecord::Base.transaction do
run_callbacks(:restore) do
update_column paranoia_column, nil
update_column(paranoia_flag_column, false) if paranoia_flag_column
restore_associated_records if opts[:recursive]
end
end
......@@ -86,7 +93,12 @@ module Paranoia
private
# touch paranoia column.
def mark_columns_deleted
update_column(paranoia_flag_column, true) if paranoia_flag_column
touch(paranoia_column)
end
# touch paranoia column, update flag column if necessary
# insert time to paranoia column.
# @param with_transaction [Boolean] exec with ActiveRecord Transactions.
def touch_paranoia_column(with_transaction=false)
......@@ -95,9 +107,11 @@ module Paranoia
# Let's not touch it if it's frozen.
unless self.frozen?
if with_transaction
with_transaction_returning_status { touch(paranoia_column) }
with_transaction_returning_status do
mark_columns_deleted
end
else
touch(paranoia_column)
mark_columns_deleted
end
end
end
......@@ -146,9 +160,13 @@ class ActiveRecord::Base
include Paranoia
class_attribute :paranoia_column
class_attribute :paranoia_flag_column
class_attribute :paranoia_indexed_column
self.paranoia_column = options[:column] || :deleted_at
default_scope { where(paranoia_column => nil) }
self.paranoia_flag_column = options[:flag_column] || nil
self.paranoia_indexed_column = options[:indexed_column] || paranoia_column
default_scope { where(paranoia_indexed_column => paranoia_false_value) }
before_restore {
self.class.notify_observers(:before_restore, self) if self.class.respond_to?(:notify_observers)
......@@ -181,6 +199,14 @@ class ActiveRecord::Base
private
def paranoia_flag_column
self.class.paranoia_flag_column
end
def paranoia_indexed_column
self.class.paranoia_indexed_column
end
def paranoia_column
self.class.paranoia_column
end
......
......@@ -14,7 +14,7 @@ ActiveRecord::Base.connection.execute 'CREATE TABLE parent_models (id INTEGER NO
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 plain_models (id INTEGER NOT NULL PRIMARY KEY, deleted_at DATETIME, is_deleted tinyint(1) not null default 0)'
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)'
......@@ -442,6 +442,32 @@ class ParanoiaTest < test_framework
# essentially, we're just ensuring that this doesn't crash
end
def test_destroy_flagged_model
a = FlaggedModel.create
assert_equal false, a.is_deleted?
a.destroy
assert_equal true, a.is_deleted?
end
def test_restore_flagged_model
a = FlaggedModel.create
a.destroy
a.restore!
assert_equal false, a.is_deleted?
assert_equal false, a.deleted?
end
def test_uses_flagged_index
a = FlaggedModelWithCustomIndex.create(is_deleted: 0)
assert_equal 1, FlaggedModelWithCustomIndex.count
a.destroy
assert_equal 0, FlaggedModelWithCustomIndex.count
assert FlaggedModelWithCustomIndex.all.to_sql.index(FlaggedModelWithCustomIndex.paranoia_indexed_column.to_s)
assert !FlaggedModelWithCustomIndex.all.to_sql.index( FlaggedModelWithCustomIndex.paranoia_column.to_s)
end
def test_i_am_the_destroyer
output = capture(:stdout) { ParanoidModel.I_AM_THE_DESTROYER! }
assert_equal %Q{
......@@ -557,3 +583,11 @@ class ParanoidModelWithBelong < ActiveRecord::Base
acts_as_paranoid
belongs_to :paranoid_model_with_has_one
end
class FlaggedModel < PlainModel
acts_as_paranoid :flag_column => :is_deleted
end
class FlaggedModelWithCustomIndex < PlainModel
acts_as_paranoid :flag_column => :is_deleted, :indexed_column => :is_deleted
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