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
## 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
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.
Per model:
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
```ruby
# pick some value
acts_as_paranoid sentinel_value: DateTime.new(0)
```
Support this new column by updating your model as such:
or globally in a rails initializer, e.g. `config/initializer/paranoia.rb`
``` ruby
class Client < ActiveRecord::Base
acts_as_paranoid :flag_column => :is_deleted
...
end
```ruby
Paranoia.default_sentinel_value = DateTime.new(0)
```
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.
require 'active_record' unless defined? ActiveRecord
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)
klazz.extend Query
klazz.extend Callbacks
......@@ -11,29 +22,19 @@ module Paranoia
def with_deleted
if ActiveRecord::VERSION::STRING >= "4.1"
unscope where: paranoia_indexed_column
unscope where: paranoia_column
else
all.tap { |x| x.default_scoped = false }
end
end
def only_deleted
with_deleted.where.not(paranoia_indexed_column => paranoia_false_value)
with_deleted.where.not(paranoia_column => paranoia_sentinel_value)
end
alias :deleted :only_deleted
def restore(id, opts = {})
if id.is_a?(Array)
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
Array(id).flatten.map { |one_id| only_deleted.find(one_id).restore!(opts) }
end
end
......@@ -82,27 +83,29 @@ module Paranoia
def restore!(opts = {})
self.class.transaction do
run_callbacks(:restore) do
update_column paranoia_column, nil
update_column(paranoia_flag_column, false) if paranoia_flag_column
# Fixes a bug where the build would error because attributes were frozen.
# 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]
end
end
self
end
alias :restore :restore!
def destroyed?
!!send(paranoia_column)
send(paranoia_column) != paranoia_sentinel_value
end
alias :deleted? :destroyed?
private
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
# touch paranoia column.
# insert time to paranoia column.
# @param with_transaction [Boolean] exec with ActiveRecord Transactions.
def touch_paranoia_column(with_transaction=false)
......@@ -111,11 +114,9 @@ module Paranoia
# Let's not touch it if it's frozen.
unless self.frozen?
if with_transaction
with_transaction_returning_status do
mark_columns_deleted
end
with_transaction_returning_status { touch(paranoia_column) }
else
mark_columns_deleted
touch(paranoia_column)
end
end
end
......@@ -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)
end
end
clear_association_cache if destroyed_associations.present?
end
end
......@@ -160,10 +163,14 @@ class ActiveRecord::Base
if dependent_reflections.any?
dependent_reflections.each do |name, _|
associated_records = self.send(name)
# Paranoid models will have this method, non-paranoid models will not
associated_records = associated_records.with_deleted if associated_records.respond_to?(:with_deleted)
associated_records.each(&:really_destroy!)
self.send(name).reload
# has_one association can return nil
if associated_records && associated_records.respond_to?(:with_deleted)
# Paranoid models will have this method, non-paranoid models will not
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
touch_paranoia_column if ActiveRecord::VERSION::STRING >= "4.1"
......@@ -171,14 +178,11 @@ class ActiveRecord::Base
end
include Paranoia
class_attribute :paranoia_column
class_attribute :paranoia_flag_column
class_attribute :paranoia_indexed_column
class_attribute :paranoia_column, :paranoia_sentinel_value
self.paranoia_column = options[:column] || :deleted_at
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) }
self.paranoia_sentinel_value = options.fetch(:sentinel_value) { Paranoia.default_sentinel_value }
default_scope { where(paranoia_column => paranoia_sentinel_value) }
before_restore {
self.class.notify_observers(:before_restore, self) if self.class.respond_to?(:notify_observers)
......@@ -211,17 +215,13 @@ 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
def paranoia_sentinel_value
self.class.paranoia_sentinel_value
end
end
require 'paranoia/rspec' if defined? RSpec
......@@ -4,6 +4,10 @@ require 'rspec/expectations'
RSpec::Matchers.define :act_as_paranoid do
match { |subject| subject.class.ancestors.include?(Paranoia) }
failure_message_for_should { "#{subject.class} should use `acts_as_paranoid`" }
failure_message_for_should_not { "#{subject.class} should not use `acts_as_paranoid`" }
failure_message { "expected #{subject.class} to 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
......@@ -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_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 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 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)'
......@@ -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 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_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)'
end
......@@ -160,6 +161,36 @@ class ParanoiaTest < test_framework
assert_equal 1, model.class.deleted.count
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
model = get_featureful_model
assert_equal 0, model.class.count
......@@ -261,6 +292,13 @@ class ParanoiaTest < test_framework
assert_equal false, model.destroyed?
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
def test_destroy_twice
model = ParanoidModel.new
......@@ -496,6 +534,21 @@ class ParanoiaTest < test_framework
assert hasOne.reload.deleted_at.nil?
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
a = ParanoidModelWithObservers.create
......@@ -513,32 +566,6 @@ 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{
......@@ -578,6 +605,21 @@ class ParanoiaTest < test_framework
connect! # Reconnect the main connection
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
def get_featureful_model
FeaturefulModel.new(:name => "not empty")
......@@ -659,6 +701,10 @@ class CustomColumnModel < ActiveRecord::Base
acts_as_paranoid column: :destroyed_at
end
class CustomSentinelModel < ActiveRecord::Base
acts_as_paranoid sentinel_value: DateTime.new(0)
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