Skip to content

Commit a92bed8

Browse files
committed
Add paranoia_destroy_attributes and paranoia_restore_attributes as extension points
Use update_columns rather than touch to update the record, for generality Unlike touch, update_columns does not create a transaction for itself, so we need to add the record to the transaction, if present. If there is not a current_transaction, the add is a no-op. This all means that delete will not invoke a transaction or run the after_commit callbacks unless called from within one, which is consistent with the Rails docs and the behavior of ActiveRecord::Base#delete.
1 parent 0694b87 commit a92bed8

File tree

4 files changed

+132
-5
lines changed

4 files changed

+132
-5
lines changed

CHANGELOG.md

+6
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# paranoia Changelog
22

3+
## Unreleased
4+
5+
* `#delete` will only run in a transaction if called from one
6+
* `#paranoia_restore_attributes` and `#paranoia_restore_attributes` can be overriden to change the fields updated by `#destroy` and `#restore`
7+
* `#only_deleted` will return rows with a null value in the paranoia column unless the sentinel value is null
8+
39
## 2.1.0 (2015-01-23)
410

511
### Major changes

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
@@ -207,6 +207,49 @@ add_index :clients, [:group_id, :other_id], where: "deleted_at IS NULL"
207207

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

210+
##### Unique Indexes
211+
212+
Becuse NULL != NULL in standard SQL, we can not simply create a unique index
213+
on the deleted_at column and expect it to enforce that there only be one record
214+
with a certain combination of values.
215+
216+
If your database supports them, good alternatives include partial indexes
217+
(above) and indexes on computed columns. E.g.
218+
219+
``` ruby
220+
add_index :clients, [:group_id, 'COALESCE(deleted_at, false)'], unique: true
221+
```
222+
223+
If not, an alternative is to create a separate column which is maintained
224+
alongside deleted_at for the sake of enforcing uniqueness. To that end,
225+
paranoia makes use of two method to make its destroy and restore actions:
226+
paranoia_restore_attributes and paranoia_destroy_attributes.
227+
228+
``` ruby
229+
add_column :clients, :active, :boolean
230+
add_index :clients, [:group_id, :active], unique: true
231+
232+
class Client < ActiveRecord::Base
233+
# optionally have paranoia make use of your unique column, so that
234+
# your lookups will benefit from the unique index
235+
acts_as_paranoid column: :active, sentinel_value: true
236+
237+
def paranoia_restore_attributes
238+
{
239+
deleted_at: nil,
240+
active: true
241+
}
242+
end
243+
244+
def paranoia_destroy_attributes
245+
{
246+
deleted_at: current_time_from_proper_timezone,
247+
active: nil
248+
}
249+
end
250+
end
251+
```
252+
210253
## Acts As Paranoid Migration
211254

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

lib/paranoia.rb

+28-4
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

@@ -85,9 +94,12 @@ def destroy
8594
def delete
8695
raise ActiveRecord::ReadOnlyRecord, "#{self.class} is marked as readonly" if readonly?
8796
if persisted?
88-
touch(paranoia_column)
97+
# if a transaction exists, add the record so that after_commit
98+
# callbacks can be run
99+
add_to_transaction
100+
update_columns(paranoia_destroy_attributes)
89101
elsif !frozen?
90-
write_attribute(paranoia_column, current_time_from_proper_timezone)
102+
assign_attributes(paranoia_destroy_attributes)
91103
end
92104
self
93105
end
@@ -100,7 +112,7 @@ def restore!(opts = {})
100112
noop_if_frozen = ActiveRecord.version < Gem::Version.new("4.1")
101113
if (noop_if_frozen && !@attributes.frozen?) || !noop_if_frozen
102114
write_attribute paranoia_column, paranoia_sentinel_value
103-
update_column paranoia_column, paranoia_sentinel_value
115+
update_columns(paranoia_restore_attributes)
104116
end
105117
restore_associated_records if opts[:recursive]
106118
end
@@ -117,6 +129,18 @@ def paranoia_destroyed?
117129

118130
private
119131

132+
def paranoia_restore_attributes
133+
{
134+
paranoia_column => paranoia_sentinel_value
135+
}
136+
end
137+
138+
def paranoia_destroy_attributes
139+
{
140+
paranoia_column => current_time_from_proper_timezone
141+
}
142+
end
143+
120144
# restore associated records that have been soft deleted when
121145
# we called #destroy
122146
def restore_associated_records

test/paranoia_test.rb

+54
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ 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+
'active_column_models' => 'deleted_at DATETIME, active BOOLEAN',
4041
}.each do |table_name, columns_as_sql_string|
4142
ActiveRecord::Base.connection.execute "CREATE TABLE #{table_name} (id INTEGER NOT NULL PRIMARY KEY, #{columns_as_sql_string})"
4243
end
@@ -120,6 +121,22 @@ def test_delete_behavior_for_plain_models_callbacks
120121
model.remove_called_variables # clear called callback flags
121122
model.delete
122123

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

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

986+
class ActiveColumnModel < ActiveRecord::Base
987+
acts_as_paranoid column: :active, sentinel_value: true
988+
989+
def paranoia_restore_attributes
990+
{
991+
deleted_at: nil,
992+
active: true
993+
}
994+
end
995+
996+
def paranoia_destroy_attributes
997+
{
998+
deleted_at: current_time_from_proper_timezone,
999+
active: nil
1000+
}
1001+
end
1002+
end
1003+
9501004
class NonParanoidModel < ActiveRecord::Base
9511005
end
9521006

0 commit comments

Comments
 (0)