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 ...@@ -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 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 ## Installation & Usage
...@@ -75,9 +75,9 @@ Hey presto, it's there! Calling `destroy` will now set the `deleted_at` column: ...@@ -75,9 +75,9 @@ Hey presto, it's there! Calling `destroy` will now set the `deleted_at` column:
``` ruby ``` ruby
>> client.deleted_at >> client.deleted_at
# => nil # => nil
>> client.destroy >> client.destroy
# => client # => client
>> client.deleted_at >> client.deleted_at
# => [current timestamp] # => [current timestamp]
``` ```
...@@ -178,6 +178,44 @@ You can replace the older `acts_as_paranoid` methods as follows: ...@@ -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(:first)` | `Client.with_deleted.first` |
|`find_with_deleted(id)` | `Client.with_deleted.find(id)` | |`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 ## License
This gem is released under the MIT license. This gem is released under the MIT license.
...@@ -11,14 +11,14 @@ module Paranoia ...@@ -11,14 +11,14 @@ module Paranoia
def with_deleted def with_deleted
if ActiveRecord::VERSION::STRING >= "4.1" if ActiveRecord::VERSION::STRING >= "4.1"
unscope where: paranoia_column unscope where: paranoia_indexed_column
else else
all.tap { |x| x.default_scoped = false } all.tap { |x| x.default_scoped = false }
end end
end end
def only_deleted def only_deleted
with_deleted.where.not(paranoia_column => nil) with_deleted.where.not(paranoia_indexed_column => paranoia_false_value)
end end
alias :deleted :only_deleted alias :deleted :only_deleted
...@@ -29,6 +29,12 @@ module Paranoia ...@@ -29,6 +29,12 @@ module Paranoia
only_deleted.find(id).restore!(opts) only_deleted.find(id).restore!(opts)
end end
end end
private
def paranoia_false_value
(paranoia_indexed_column == paranoia_column) ? nil : 0
end
end end
module Callbacks module Callbacks
...@@ -73,6 +79,7 @@ module Paranoia ...@@ -73,6 +79,7 @@ module Paranoia
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
run_callbacks(:restore) do run_callbacks(:restore) do
update_column paranoia_column, nil update_column paranoia_column, nil
update_column(paranoia_flag_column, false) if paranoia_flag_column
restore_associated_records if opts[:recursive] restore_associated_records if opts[:recursive]
end end
end end
...@@ -86,7 +93,12 @@ module Paranoia ...@@ -86,7 +93,12 @@ module Paranoia
private 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. # insert time to paranoia column.
# @param with_transaction [Boolean] exec with ActiveRecord Transactions. # @param with_transaction [Boolean] exec with ActiveRecord Transactions.
def touch_paranoia_column(with_transaction=false) def touch_paranoia_column(with_transaction=false)
...@@ -95,9 +107,11 @@ module Paranoia ...@@ -95,9 +107,11 @@ module Paranoia
# Let's not touch it if it's frozen. # Let's not touch it if it's frozen.
unless self.frozen? unless self.frozen?
if with_transaction if with_transaction
with_transaction_returning_status { touch(paranoia_column) } with_transaction_returning_status do
mark_columns_deleted
end
else else
touch(paranoia_column) mark_columns_deleted
end end
end end
end end
...@@ -146,9 +160,13 @@ class ActiveRecord::Base ...@@ -146,9 +160,13 @@ class ActiveRecord::Base
include Paranoia include Paranoia
class_attribute :paranoia_column class_attribute :paranoia_column
class_attribute :paranoia_flag_column
class_attribute :paranoia_indexed_column
self.paranoia_column = options[:column] || :deleted_at 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 { before_restore {
self.class.notify_observers(:before_restore, self) if self.class.respond_to?(:notify_observers) self.class.notify_observers(:before_restore, self) if self.class.respond_to?(:notify_observers)
...@@ -181,6 +199,14 @@ class ActiveRecord::Base ...@@ -181,6 +199,14 @@ class ActiveRecord::Base
private private
def paranoia_flag_column
self.class.paranoia_flag_column
end
def paranoia_indexed_column
self.class.paranoia_indexed_column
end
def paranoia_column def paranoia_column
self.class.paranoia_column self.class.paranoia_column
end end
......
...@@ -14,7 +14,7 @@ ActiveRecord::Base.connection.execute 'CREATE TABLE parent_models (id INTEGER NO ...@@ -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_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 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 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 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 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)' 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 ...@@ -442,6 +442,32 @@ class ParanoiaTest < test_framework
# essentially, we're just ensuring that this doesn't crash # essentially, we're just ensuring that this doesn't crash
end 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 def test_i_am_the_destroyer
output = capture(:stdout) { ParanoidModel.I_AM_THE_DESTROYER! } output = capture(:stdout) { ParanoidModel.I_AM_THE_DESTROYER! }
assert_equal %Q{ assert_equal %Q{
...@@ -557,3 +583,11 @@ class ParanoidModelWithBelong < ActiveRecord::Base ...@@ -557,3 +583,11 @@ class ParanoidModelWithBelong < ActiveRecord::Base
acts_as_paranoid acts_as_paranoid
belongs_to :paranoid_model_with_has_one belongs_to :paranoid_model_with_has_one
end 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