Skip to content

Commit bb98963

Browse files
committed
Merge pull request #245 from brigade/update_attributes
Introduce #paranoia_destroy_attributes and #paranoia_restore_attributes extension points
2 parents 3b6ff6e + b2b8d19 commit bb98963

File tree

3 files changed

+131
-16
lines changed

3 files changed

+131
-16
lines changed

README.md

+44-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ If you wish to actually destroy an object you may call `really_destroy!`. **WARN
99
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.
1010

1111
## Getting Started Video
12-
Setup and basic usage of the paranoia gem
12+
Setup and basic usage of the paranoia gem
1313
[GoRails #41](https://gorails.com/episodes/soft-delete-with-paranoia)
1414

1515
## Installation & Usage
@@ -185,6 +185,49 @@ add_index :clients, [:group_id, :other_id], where: "deleted_at IS NULL"
185185

186186
Of course, this is not necessary for the indexes you always use in association with `with_deleted` or `only_deleted`.
187187

188+
##### Unique Indexes
189+
190+
Becuse NULL != NULL in standard SQL, we can not simply create a unique index
191+
on the deleted_at column and expect it to enforce that there only be one record
192+
with a certain combination of values.
193+
194+
If your database supports them, good alternatives include partial indexes
195+
(above) and indexes on computed columns. E.g.
196+
197+
``` ruby
198+
add_index :clients, [:group_id, 'COALESCE(deleted_at, false)'], unique: true
199+
```
200+
201+
If not, an alternative is to create a separate column which is maintained
202+
alongside deleted_at for the sake of enforcing uniqueness. To that end,
203+
paranoia makes use of two method to make its destroy and restore actions:
204+
paranoia_restore_attributes and paranoia_destroy_attributes.
205+
206+
``` ruby
207+
add_column :clients, :active, :boolean
208+
add_index :clients, [:group_id, :active], unique: true
209+
210+
class Client < ActiveRecord::Base
211+
# optionally have paranoia make use of your unique column, so that
212+
# your lookups will benefit from the unique index
213+
acts_as_paranoid column: :active, sentinel_value: true
214+
215+
def paranoia_restore_attributes
216+
{
217+
deleted_at: nil,
218+
active: true
219+
}
220+
end
221+
222+
def paranoia_destroy_attributes
223+
{
224+
deleted_at: current_time_from_proper_timezone,
225+
active: nil
226+
}
227+
end
228+
end
229+
```
230+
188231
## Acts As Paranoid Migration
189232

190233
You can replace the older `acts_as_paranoid` methods as follows:

lib/paranoia.rb

+32-14
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,16 @@ def with_deleted
2929
end
3030

3131
def only_deleted
32-
with_deleted.where.not(paranoia_column => paranoia_sentinel_value)
32+
if paranoia_sentinel_value.nil?
33+
with_deleted.where.not(paranoia_column => paranoia_sentinel_value)
34+
else
35+
# if paranoia_sentinel_value is not null, then it is possible that
36+
# some deleted rows will hold a null value in the paranoia column
37+
# these will not match != sentinel value because "NULL != value" is
38+
# NULL under the sql standard
39+
quoted_paranoia_column = connection.quote_column_name(paranoia_column)
40+
with_deleted.where("#{quoted_paranoia_column} IS NULL OR #{quoted_paranoia_column} != ?", paranoia_sentinel_value)
41+
end
3342
end
3443
alias :deleted :only_deleted
3544

@@ -68,7 +77,7 @@ def self.extended(klazz)
6877
def destroy
6978
transaction do
7079
run_callbacks(:destroy) do
71-
result = touch_paranoia_column
80+
result = delete
7281
if result && ActiveRecord::VERSION::STRING >= '4.2'
7382
each_counter_cached_associations do |association|
7483
foreign_key = association.reflection.foreign_key.to_sym
@@ -85,7 +94,16 @@ def destroy
8594
end
8695

8796
def delete
88-
touch_paranoia_column
97+
raise ActiveRecord::ReadOnlyRecord, "#{self.class} is marked as readonly" if readonly?
98+
if persisted?
99+
# if a transaction exists, add the record so that after_commit
100+
# callbacks can be run
101+
add_to_transaction
102+
update_columns(paranoia_destroy_attributes)
103+
elsif !frozen?
104+
assign_attributes(paranoia_destroy_attributes)
105+
end
106+
self
89107
end
90108

91109
def restore!(opts = {})
@@ -96,7 +114,7 @@ def restore!(opts = {})
96114
noop_if_frozen = ActiveRecord.version < Gem::Version.new("4.1")
97115
if (noop_if_frozen && !@attributes.frozen?) || !noop_if_frozen
98116
write_attribute paranoia_column, paranoia_sentinel_value
99-
update_column paranoia_column, paranoia_sentinel_value
117+
update_columns(paranoia_restore_attributes)
100118
end
101119
restore_associated_records if opts[:recursive]
102120
end
@@ -113,16 +131,16 @@ def paranoia_destroyed?
113131

114132
private
115133

116-
# touch paranoia column.
117-
# insert time to paranoia column.
118-
def touch_paranoia_column
119-
raise ActiveRecord::ReadOnlyRecord, "#{self.class} is marked as readonly" if readonly?
120-
if persisted?
121-
touch(paranoia_column)
122-
elsif !frozen?
123-
write_attribute(paranoia_column, current_time_from_proper_timezone)
124-
end
125-
self
134+
def paranoia_restore_attributes
135+
{
136+
paranoia_column => paranoia_sentinel_value
137+
}
138+
end
139+
140+
def paranoia_destroy_attributes
141+
{
142+
paranoia_column => current_time_from_proper_timezone
143+
}
126144
end
127145

128146
# restore associated records that have been soft deleted when

test/paranoia_test.rb

+55-1
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ def setup!
3737
'polymorphic_models' => 'parent_id INTEGER, parent_type STRING, deleted_at DATETIME',
3838
'namespaced_paranoid_has_ones' => 'deleted_at DATETIME, paranoid_belongs_tos_id INTEGER',
3939
'namespaced_paranoid_belongs_tos' => 'deleted_at DATETIME, paranoid_has_one_id INTEGER',
40-
'unparanoid_unique_models' => 'name VARCHAR(32), paranoid_with_unparanoids_id INTEGER'
40+
'unparanoid_unique_models' => 'name VARCHAR(32), paranoid_with_unparanoids_id INTEGER',
41+
'active_column_models' => 'deleted_at DATETIME, active BOOLEAN'
4142
}.each do |table_name, columns_as_sql_string|
4243
ActiveRecord::Base.connection.execute "CREATE TABLE #{table_name} (id INTEGER NOT NULL PRIMARY KEY, #{columns_as_sql_string})"
4344
end
@@ -121,6 +122,22 @@ def test_delete_behavior_for_plain_models_callbacks
121122
model.remove_called_variables # clear called callback flags
122123
model.delete
123124

125+
assert_equal nil, model.instance_variable_get(:@update_callback_called)
126+
assert_equal nil, model.instance_variable_get(:@save_callback_called)
127+
assert_equal nil, model.instance_variable_get(:@validate_called)
128+
assert_equal nil, model.instance_variable_get(:@destroy_callback_called)
129+
assert_equal nil, model.instance_variable_get(:@after_destroy_callback_called)
130+
assert_equal nil, model.instance_variable_get(:@after_commit_callback_called)
131+
end
132+
133+
def test_delete_in_transaction_behavior_for_plain_models_callbacks
134+
model = CallbackModel.new
135+
model.save
136+
model.remove_called_variables # clear called callback flags
137+
CallbackModel.transaction do
138+
model.delete
139+
end
140+
124141
assert_equal nil, model.instance_variable_get(:@update_callback_called)
125142
assert_equal nil, model.instance_variable_get(:@save_callback_called)
126143
assert_equal nil, model.instance_variable_get(:@validate_called)
@@ -185,6 +202,25 @@ def test_default_sentinel_value
185202
assert_equal nil, ParanoidModel.paranoia_sentinel_value
186203
end
187204

205+
def test_active_column_model
206+
model = ActiveColumnModel.new
207+
assert_equal 0, model.class.count
208+
model.save!
209+
assert_nil model.deleted_at
210+
assert_equal true, model.active
211+
assert_equal 1, model.class.count
212+
model.destroy
213+
214+
assert_equal false, model.deleted_at.nil?
215+
assert_nil model.active
216+
assert model.paranoia_destroyed?
217+
218+
assert_equal 0, model.class.count
219+
assert_equal 1, model.class.unscoped.count
220+
assert_equal 1, model.class.only_deleted.count
221+
assert_equal 1, model.class.deleted.count
222+
end
223+
188224
def test_sentinel_value_for_custom_sentinel_models
189225
model = CustomSentinelModel.new
190226
assert_equal 0, model.class.count
@@ -978,6 +1014,24 @@ class CustomSentinelModel < ActiveRecord::Base
9781014
acts_as_paranoid sentinel_value: DateTime.new(0)
9791015
end
9801016

1017+
class ActiveColumnModel < ActiveRecord::Base
1018+
acts_as_paranoid column: :active, sentinel_value: true
1019+
1020+
def paranoia_restore_attributes
1021+
{
1022+
deleted_at: nil,
1023+
active: true
1024+
}
1025+
end
1026+
1027+
def paranoia_destroy_attributes
1028+
{
1029+
deleted_at: current_time_from_proper_timezone,
1030+
active: nil
1031+
}
1032+
end
1033+
end
1034+
9811035
class NonParanoidModel < ActiveRecord::Base
9821036
end
9831037

0 commit comments

Comments
 (0)