Commit c98d4083 by ray

Merge branch 'rails4' into setting_restore

Conflicts:
	test/paranoia_test.rb
parents 93daa9b2 ceaaecb1
...@@ -184,42 +184,25 @@ The `recover` method in `acts_as_paranoid` runs `update` callbacks. Paranoia's ...@@ -184,42 +184,25 @@ The `recover` method in `acts_as_paranoid` runs `update` callbacks. Paranoia's
## Support for Unique Keys with Null Values ## 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. Most databases ignore null columns when it comes to resolving unique index
constraints. This means unique constraints that involve nullable columns may be
problematic. Instead of using `NULL` to represent a not-deleted row, you can pick
a value that you want paranoia to mean not deleted. Note that you can/should
now apply a `NOT NULL` constraint to your `deleted_at` column.
``` ruby Per model:
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
# pick some value
``` ruby acts_as_paranoid sentinel_value: DateTime.new(0)
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: or globally in a rails initializer, e.g. `config/initializer/paranoia.rb`
``` ruby ```ruby
class Client < ActiveRecord::Base Paranoia.default_sentinel_value = DateTime.new(0)
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.
require 'active_record' unless defined? ActiveRecord require 'active_record' unless defined? ActiveRecord
module Paranoia module Paranoia
@@default_sentinel_value = nil
# Change default_sentinel_value in a rails initilizer
def self.default_sentinel_value=(val)
@@default_sentinel_value = val
end
def self.default_sentinel_value
@@default_sentinel_value
end
def self.included(klazz) def self.included(klazz)
klazz.extend Query klazz.extend Query
klazz.extend Callbacks klazz.extend Callbacks
...@@ -11,29 +22,19 @@ module Paranoia ...@@ -11,29 +22,19 @@ module Paranoia
def with_deleted def with_deleted
if ActiveRecord::VERSION::STRING >= "4.1" if ActiveRecord::VERSION::STRING >= "4.1"
unscope where: paranoia_indexed_column unscope where: paranoia_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_indexed_column => paranoia_false_value) with_deleted.where.not(paranoia_column => paranoia_sentinel_value)
end end
alias :deleted :only_deleted alias :deleted :only_deleted
def restore(id, opts = {}) def restore(id, opts = {})
if id.is_a?(Array) Array(id).flatten.map { |one_id| only_deleted.find(one_id).restore!(opts) }
id.map { |one_id| restore(one_id, opts) }
else
only_deleted.find(id).restore!(opts)
end
end
private
def paranoia_false_value
(paranoia_indexed_column == paranoia_column) ? nil : 0
end end
end end
...@@ -82,27 +83,29 @@ module Paranoia ...@@ -82,27 +83,29 @@ module Paranoia
def restore!(opts = {}) def restore!(opts = {})
self.class.transaction do self.class.transaction do
run_callbacks(:restore) do run_callbacks(:restore) do
update_column paranoia_column, nil # Fixes a bug where the build would error because attributes were frozen.
update_column(paranoia_flag_column, false) if paranoia_flag_column # This only happened on Rails versions earlier than 4.1.
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
end
restore_associated_records if opts[:recursive] restore_associated_records if opts[:recursive]
end end
end end
self
end end
alias :restore :restore! alias :restore :restore!
def destroyed? def destroyed?
!!send(paranoia_column) send(paranoia_column) != paranoia_sentinel_value
end end
alias :deleted? :destroyed? alias :deleted? :destroyed?
private private
def mark_columns_deleted # touch paranoia column.
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)
...@@ -111,11 +114,9 @@ module Paranoia ...@@ -111,11 +114,9 @@ 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 do with_transaction_returning_status { touch(paranoia_column) }
mark_columns_deleted
end
else else
mark_columns_deleted touch(paranoia_column)
end end
end end
end end
...@@ -146,6 +147,8 @@ module Paranoia ...@@ -146,6 +147,8 @@ module Paranoia
Object.const_get(association_class_name).only_deleted.where(association_foreign_key, self.id).first.try(:restore, recursive: true) Object.const_get(association_class_name).only_deleted.where(association_foreign_key, self.id).first.try(:restore, recursive: true)
end end
end end
clear_association_cache if destroyed_associations.present?
end end
end end
...@@ -160,10 +163,14 @@ class ActiveRecord::Base ...@@ -160,10 +163,14 @@ class ActiveRecord::Base
if dependent_reflections.any? if dependent_reflections.any?
dependent_reflections.each do |name, _| dependent_reflections.each do |name, _|
associated_records = self.send(name) associated_records = self.send(name)
# Paranoid models will have this method, non-paranoid models will not # has_one association can return nil
associated_records = associated_records.with_deleted if associated_records.respond_to?(:with_deleted) if associated_records && associated_records.respond_to?(:with_deleted)
associated_records.each(&:really_destroy!) # Paranoid models will have this method, non-paranoid models will not
self.send(name).reload associated_records.with_deleted.each(&:really_destroy!)
self.send(name).reload
elsif associated_records && !associated_records.respond_to?(:each) # single record
associated_records.really_destroy!
end
end end
end end
touch_paranoia_column if ActiveRecord::VERSION::STRING >= "4.1" touch_paranoia_column if ActiveRecord::VERSION::STRING >= "4.1"
...@@ -171,14 +178,11 @@ class ActiveRecord::Base ...@@ -171,14 +178,11 @@ class ActiveRecord::Base
end end
include Paranoia include Paranoia
class_attribute :paranoia_column class_attribute :paranoia_column, :paranoia_sentinel_value
class_attribute :paranoia_flag_column
class_attribute :paranoia_indexed_column
self.paranoia_column = options[:column] || :deleted_at self.paranoia_column = options[:column] || :deleted_at
self.paranoia_flag_column = options[:flag_column] || nil self.paranoia_sentinel_value = options.fetch(:sentinel_value) { Paranoia.default_sentinel_value }
self.paranoia_indexed_column = options[:indexed_column] || paranoia_column default_scope { where(paranoia_column => paranoia_sentinel_value) }
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)
...@@ -211,17 +215,13 @@ class ActiveRecord::Base ...@@ -211,17 +215,13 @@ 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
def paranoia_sentinel_value
self.class.paranoia_sentinel_value
end
end end
require 'paranoia/rspec' if defined? RSpec require 'paranoia/rspec' if defined? RSpec
...@@ -4,6 +4,10 @@ require 'rspec/expectations' ...@@ -4,6 +4,10 @@ require 'rspec/expectations'
RSpec::Matchers.define :act_as_paranoid do RSpec::Matchers.define :act_as_paranoid do
match { |subject| subject.class.ancestors.include?(Paranoia) } match { |subject| subject.class.ancestors.include?(Paranoia) }
failure_message_for_should { "#{subject.class} should use `acts_as_paranoid`" } failure_message { "expected #{subject.class} to use `acts_as_paranoid`" }
failure_message_for_should_not { "#{subject.class} should not use `acts_as_paranoid`" } failure_message_when_negated { "expected #{subject.class} not to use `acts_as_paranoid`" }
# RSpec 2 compatibility:
alias_method :failure_message_for_should, :failure_message
alias_method :failure_message_for_should_not, :failure_message_when_negated
end end
...@@ -17,7 +17,7 @@ def connect! ...@@ -17,7 +17,7 @@ def connect!
ActiveRecord::Base.connection.execute 'CREATE TABLE paranoid_model_with_anthor_class_name_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_anthor_class_name_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_foreign_key_belongs (id INTEGER NOT NULL PRIMARY KEY, parent_model_id INTEGER, deleted_at DATETIME, has_one_foreign_key_id INTEGER)' ActiveRecord::Base.connection.execute 'CREATE TABLE paranoid_model_with_foreign_key_belongs (id INTEGER NOT NULL PRIMARY KEY, parent_model_id INTEGER, deleted_at DATETIME, has_one_foreign_key_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, is_deleted tinyint(1) not null default 0)' ActiveRecord::Base.connection.execute 'CREATE TABLE plain_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 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)'
...@@ -26,6 +26,7 @@ def connect! ...@@ -26,6 +26,7 @@ def connect!
ActiveRecord::Base.connection.execute 'CREATE TABLE employees (id INTEGER NOT NULL PRIMARY KEY, deleted_at DATETIME)' ActiveRecord::Base.connection.execute 'CREATE TABLE employees (id INTEGER NOT NULL PRIMARY KEY, deleted_at DATETIME)'
ActiveRecord::Base.connection.execute 'CREATE TABLE jobs (id INTEGER NOT NULL PRIMARY KEY, employer_id INTEGER NOT NULL, employee_id INTEGER NOT NULL, deleted_at DATETIME)' ActiveRecord::Base.connection.execute 'CREATE TABLE jobs (id INTEGER NOT NULL PRIMARY KEY, employer_id INTEGER NOT NULL, employee_id INTEGER NOT NULL, deleted_at DATETIME)'
ActiveRecord::Base.connection.execute 'CREATE TABLE custom_column_models (id INTEGER NOT NULL PRIMARY KEY, destroyed_at DATETIME)' ActiveRecord::Base.connection.execute 'CREATE TABLE custom_column_models (id INTEGER NOT NULL PRIMARY KEY, destroyed_at DATETIME)'
ActiveRecord::Base.connection.execute 'CREATE TABLE custom_sentinel_models (id INTEGER NOT NULL PRIMARY KEY, deleted_at DATETIME NOT NULL)'
ActiveRecord::Base.connection.execute 'CREATE TABLE non_paranoid_models (id INTEGER NOT NULL PRIMARY KEY, parent_model_id INTEGER)' ActiveRecord::Base.connection.execute 'CREATE TABLE non_paranoid_models (id INTEGER NOT NULL PRIMARY KEY, parent_model_id INTEGER)'
end end
...@@ -160,6 +161,36 @@ class ParanoiaTest < test_framework ...@@ -160,6 +161,36 @@ class ParanoiaTest < test_framework
assert_equal 1, model.class.deleted.count assert_equal 1, model.class.deleted.count
end end
def test_default_sentinel_value
assert_equal nil, ParanoidModel.paranoia_sentinel_value
end
def test_sentinel_value_for_custom_sentinel_models
model = CustomSentinelModel.new
assert_equal 0, model.class.count
model.save!
assert_equal DateTime.new(0), model.deleted_at
assert_equal 1, model.class.count
model.destroy
assert DateTime.new(0) != model.deleted_at
assert model.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
model.restore
assert_equal DateTime.new(0), model.deleted_at
assert !model.destroyed?
assert_equal 1, model.class.count
assert_equal 1, model.class.unscoped.count
assert_equal 0, model.class.only_deleted.count
assert_equal 0, model.class.deleted.count
end
def test_destroy_behavior_for_featureful_paranoid_models def test_destroy_behavior_for_featureful_paranoid_models
model = get_featureful_model model = get_featureful_model
assert_equal 0, model.class.count assert_equal 0, model.class.count
...@@ -261,6 +292,13 @@ class ParanoiaTest < test_framework ...@@ -261,6 +292,13 @@ class ParanoiaTest < test_framework
assert_equal false, model.destroyed? assert_equal false, model.destroyed?
end end
def test_restore_on_object_return_self
model = ParanoidModel.create
model.destroy
assert_equal model.class, model.restore.class
end
# Regression test for #92 # Regression test for #92
def test_destroy_twice def test_destroy_twice
model = ParanoidModel.new model = ParanoidModel.new
...@@ -496,6 +534,21 @@ class ParanoiaTest < test_framework ...@@ -496,6 +534,21 @@ class ParanoiaTest < test_framework
assert hasOne.reload.deleted_at.nil? assert hasOne.reload.deleted_at.nil?
end end
# covers #131
def test_has_one_really_destroy_with_nil
model = ParanoidModelWithHasOne.create
model.really_destroy!
refute ParanoidModelWithBelong.unscoped.exists?(model.id)
end
def test_has_one_really_destroy_with_record
model = ParanoidModelWithHasOne.create { |record| record.build_paranoid_model_with_belong }
model.really_destroy!
refute ParanoidModelWithBelong.unscoped.exists?(model.id)
end
def test_observers_notified def test_observers_notified
a = ParanoidModelWithObservers.create a = ParanoidModelWithObservers.create
...@@ -513,32 +566,6 @@ class ParanoiaTest < test_framework ...@@ -513,32 +566,6 @@ 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{
...@@ -578,6 +605,21 @@ class ParanoiaTest < test_framework ...@@ -578,6 +605,21 @@ class ParanoiaTest < test_framework
connect! # Reconnect the main connection connect! # Reconnect the main connection
end end
def test_restore_clear_association_cache_if_associations_present
parent = ParentModel.create
3.times { parent.very_related_models.create }
parent.destroy
assert_equal 0, parent.very_related_models.count
assert_equal 0, parent.very_related_models.size
parent.restore(recursive: true)
assert_equal 3, parent.very_related_models.count
assert_equal 3, parent.very_related_models.size
end
private private
def get_featureful_model def get_featureful_model
FeaturefulModel.new(:name => "not empty") FeaturefulModel.new(:name => "not empty")
...@@ -659,6 +701,10 @@ class CustomColumnModel < ActiveRecord::Base ...@@ -659,6 +701,10 @@ class CustomColumnModel < ActiveRecord::Base
acts_as_paranoid column: :destroyed_at acts_as_paranoid column: :destroyed_at
end end
class CustomSentinelModel < ActiveRecord::Base
acts_as_paranoid sentinel_value: DateTime.new(0)
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