acts_as_paranoid 0.3.1 → 0.4.0
Sign up to get free protection for your applications and to get access to all the features.
- data/{MIT-LICENSE → LICENSE} +2 -2
- data/README.markdown +248 -0
- data/lib/acts_as_paranoid/associations.rb +34 -0
- data/lib/acts_as_paranoid/core.rb +184 -0
- data/lib/acts_as_paranoid/relation.rb +36 -0
- data/lib/acts_as_paranoid/validations.rb +43 -0
- data/lib/rails3_acts_as_paranoid.rb +61 -0
- metadata +65 -46
- data/CHANGELOG +0 -47
- data/README +0 -26
- data/RUNNING_UNIT_TESTS +0 -41
- data/lib/acts_as_paranoid.rb +0 -166
- data/test/database.yml +0 -18
- data/test/debug.log +0 -704
- data/test/fixtures/categories.yml +0 -19
- data/test/fixtures/categories_widgets.yml +0 -12
- data/test/fixtures/widgets.yml +0 -8
- data/test/paranoid_test.rb +0 -151
- data/test/schema.rb +0 -20
- data/test/test_helper.rb +0 -36
data/{MIT-LICENSE → LICENSE}
RENAMED
@@ -1,4 +1,4 @@
|
|
1
|
-
Copyright (c)
|
1
|
+
Copyright (c) 2010 Gonçalo Silva
|
2
2
|
|
3
3
|
Permission is hereby granted, free of charge, to any person obtaining
|
4
4
|
a copy of this software and associated documentation files (the
|
@@ -17,4 +17,4 @@ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
17
17
|
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
18
|
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
19
|
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
-
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.markdown
ADDED
@@ -0,0 +1,248 @@
|
|
1
|
+
# ActsAsParanoid
|
2
|
+
|
3
|
+
A simple plugin which hides records instead of deleting them, being able to recover them.
|
4
|
+
|
5
|
+
**This branch targets Rails 3.2.** If you're working with another version, switch to the corresponding branch.
|
6
|
+
|
7
|
+
## Credits
|
8
|
+
|
9
|
+
This plugin was inspired by [acts_as_paranoid](http://github.com/technoweenie/acts_as_paranoid) and [acts_as_active](http://github.com/fernandoluizao/acts_as_active).
|
10
|
+
|
11
|
+
While porting it to Rails 3, I decided to apply the ideas behind those plugins to an unified solution while removing a **lot** of the complexity found in them. I eventually ended up writing a new plugin from scratch.
|
12
|
+
|
13
|
+
## Usage
|
14
|
+
|
15
|
+
You can enable ActsAsParanoid like this:
|
16
|
+
|
17
|
+
```ruby
|
18
|
+
class Paranoiac < ActiveRecord::Base
|
19
|
+
acts_as_paranoid
|
20
|
+
end
|
21
|
+
```
|
22
|
+
|
23
|
+
### Options
|
24
|
+
|
25
|
+
You can also specify the name of the column to store it's *deletion* and the type of data it holds:
|
26
|
+
|
27
|
+
- `:column => 'deleted_at'`
|
28
|
+
- `:column_type => 'time'`
|
29
|
+
|
30
|
+
The values shown are the defaults. While *column* can be anything (as long as it exists in your database), *type* is restricted to:
|
31
|
+
|
32
|
+
- `boolean`
|
33
|
+
- `time` or
|
34
|
+
- `string`
|
35
|
+
|
36
|
+
If your column type is a `string`, you can also specify which value to use when marking an object as deleted by passing `:deleted_value` (default is "deleted"). Any records with a non-matching value in this column will be treated normally (ie: not deleted).
|
37
|
+
|
38
|
+
### Filtering
|
39
|
+
|
40
|
+
If a record is deleted by ActsAsParanoid, it won't be retrieved when accessing the database. So, `Paranoiac.all` will **not** include the deleted_records. if you want to access them, you have 2 choices:
|
41
|
+
|
42
|
+
```ruby
|
43
|
+
Paranoiac.only_deleted # retrieves the deleted records
|
44
|
+
Paranoiac.with_deleted # retrieves all records, deleted or not
|
45
|
+
```
|
46
|
+
|
47
|
+
When using the default `column_type` of `'time'`, the following extra scopes are provided:
|
48
|
+
|
49
|
+
```ruby
|
50
|
+
time = Time.now
|
51
|
+
|
52
|
+
Paranoiac.deleted_after_time(time)
|
53
|
+
Paranoiac.deleted_before_time(time)
|
54
|
+
|
55
|
+
# Or roll it all up and get a nice window:
|
56
|
+
Paranoiac.deleted_inside_time_window(time, 2.minutes)
|
57
|
+
```
|
58
|
+
|
59
|
+
### Real deletion
|
60
|
+
|
61
|
+
In order to really delete a record, just use:
|
62
|
+
|
63
|
+
```ruby
|
64
|
+
paranoiac.destroy!
|
65
|
+
Paranoiac.delete_all!(conditions)
|
66
|
+
```
|
67
|
+
|
68
|
+
You can also permanently delete a record by calling `destroy` or `delete_all` on it **twice**. If a record was already deleted (hidden by ActsAsParanoid) and you delete it again, it will be removed from the database. Take this example:
|
69
|
+
|
70
|
+
```ruby
|
71
|
+
p = Paranoiac.first
|
72
|
+
p.destroy # does NOT delete the first record, just hides it
|
73
|
+
Paranoiac.only_deleted.where(:id => p.id).destroy # deletes the first record from the database
|
74
|
+
```
|
75
|
+
|
76
|
+
### Recovery
|
77
|
+
|
78
|
+
Recovery is easy. Just invoke `recover` on it, like this:
|
79
|
+
|
80
|
+
```ruby
|
81
|
+
Paranoiac.only_deleted.where("name = ?", "not dead yet").first.recover
|
82
|
+
```
|
83
|
+
|
84
|
+
All associations marked as `:dependent => :destroy` are also recursively recovered. If you would like to disable this behavior, you can call `recover` with the `recursive` option:
|
85
|
+
|
86
|
+
```ruby
|
87
|
+
Paranoiac.only_deleted.where("name = ?", "not dead yet").first.recover(:recursive => false)
|
88
|
+
```
|
89
|
+
|
90
|
+
If you would like to change this default behavior for one model, you can use the `recover_dependent_associations` option
|
91
|
+
|
92
|
+
```ruby
|
93
|
+
class Paranoiac < ActiveRecord::Base
|
94
|
+
acts_as_paranoid :recover_dependent_associations => false
|
95
|
+
end
|
96
|
+
```
|
97
|
+
|
98
|
+
By default, dependent records will be recovered if they were deleted within 2 minutes of the object upon which they depend. This restores the objects to the state before the recursive deletion without restoring other objects that were deleted earlier. The behavior is only available when both parent and dependant are using timestamp fields to mark deletion, which is the default behavior. This window can be changed with the `dependent_recovery_window` option:
|
99
|
+
|
100
|
+
```ruby
|
101
|
+
class Paranoiac < ActiveRecord::Base
|
102
|
+
acts_as_paranoid
|
103
|
+
has_many :paranoids, :dependent => :destroy
|
104
|
+
end
|
105
|
+
|
106
|
+
class Paranoid < ActiveRecord::Base
|
107
|
+
belongs_to :paranoic
|
108
|
+
|
109
|
+
# Paranoid objects will be recovered alongside Paranoic objects
|
110
|
+
# if they were deleted within 10 minutes of the Paranoic object
|
111
|
+
acts_as_paranoid :dependent_recovery_window => 10.minutes
|
112
|
+
end
|
113
|
+
```
|
114
|
+
|
115
|
+
or in the recover statement
|
116
|
+
|
117
|
+
```ruby
|
118
|
+
Paranoiac.only_deleted.where("name = ?", "not dead yet").first.recover(:recovery_window => 30.seconds)
|
119
|
+
```
|
120
|
+
|
121
|
+
### Validation
|
122
|
+
ActiveRecord's built-in uniqueness validation does not account for records deleted by ActsAsParanoid. If you want to check for uniqueness among non-deleted records only, use the macro `validates_as_paranoid` in your model. Then, instead of using `validates_uniqueness_of`, use `validates_uniqueness_of_without_deleted`. This will keep deleted records from counting against the uniqueness check.
|
123
|
+
|
124
|
+
```ruby
|
125
|
+
class Paranoiac < ActiveRecord::Base
|
126
|
+
acts_as_paranoid
|
127
|
+
validates_as_paranoid
|
128
|
+
validates_uniqueness_of_without_deleted :name
|
129
|
+
end
|
130
|
+
|
131
|
+
p1 = Paranoiac.create(:name => 'foo')
|
132
|
+
p1.destroy
|
133
|
+
|
134
|
+
p2 = Paranoiac.new(:name => 'foo')
|
135
|
+
p2.valid? #=> true
|
136
|
+
p2.save
|
137
|
+
|
138
|
+
p1.recover #=> fails validation!
|
139
|
+
```
|
140
|
+
|
141
|
+
### Status
|
142
|
+
You can check the status of your paranoid objects with the `deleted?` helper
|
143
|
+
|
144
|
+
```ruby
|
145
|
+
Paranoiac.create(:name => 'foo').destroy
|
146
|
+
Paranoiac.with_deleted.first.deleted? #=> true
|
147
|
+
```
|
148
|
+
|
149
|
+
### Scopes
|
150
|
+
|
151
|
+
As you've probably guessed, `with_deleted` and `only_deleted` are scopes. You can, however, chain them freely with other scopes you might have. This
|
152
|
+
|
153
|
+
```ruby
|
154
|
+
Paranoiac.pretty.with_deleted
|
155
|
+
```
|
156
|
+
|
157
|
+
is exactly the same as
|
158
|
+
|
159
|
+
```ruby
|
160
|
+
Paranoiac.with_deleted.pretty
|
161
|
+
```
|
162
|
+
|
163
|
+
You can work freely with scopes and it will just work:
|
164
|
+
|
165
|
+
```ruby
|
166
|
+
class Paranoiac < ActiveRecord::Base
|
167
|
+
acts_as_paranoid
|
168
|
+
scope :pretty, where(:pretty => true)
|
169
|
+
end
|
170
|
+
|
171
|
+
Paranoiac.create(:pretty => true)
|
172
|
+
|
173
|
+
Paranoiac.pretty.count #=> 1
|
174
|
+
Paranoiac.only_deleted.count #=> 0
|
175
|
+
Paranoiac.pretty.only_deleted.count #=> 0
|
176
|
+
|
177
|
+
Paranoiac.first.destroy
|
178
|
+
|
179
|
+
Paranoiac.pretty.count #=> 0
|
180
|
+
Paranoiac.only_deleted.count #=> 1
|
181
|
+
Paranoiac.pretty.only_deleted.count #=> 1
|
182
|
+
```
|
183
|
+
|
184
|
+
### Associations
|
185
|
+
|
186
|
+
Associations are also supported. From the simplest behaviors you'd expect to more nifty things like the ones mentioned previously or the usage of the `:with_deleted` option with `belongs_to`
|
187
|
+
|
188
|
+
```ruby
|
189
|
+
class ParanoiacParent < ActiveRecord::Base
|
190
|
+
has_many :children, :class_name => "ParanoiacChild"
|
191
|
+
end
|
192
|
+
|
193
|
+
class ParanoiacChild < ActiveRecord::Base
|
194
|
+
belongs_to :parent, :class_name => "ParanoiacParent"
|
195
|
+
belongs_to :parent_with_deleted, :class_name => "ParanoiacParent", :with_deleted => true
|
196
|
+
end
|
197
|
+
|
198
|
+
parent = ParanoiacParent.first
|
199
|
+
child = parent.children.create
|
200
|
+
parent.destroy
|
201
|
+
|
202
|
+
child.parent #=> nil
|
203
|
+
child.parent_with_deleted #=> ParanoiacParent (it works!)
|
204
|
+
```
|
205
|
+
|
206
|
+
## Caveats
|
207
|
+
|
208
|
+
Watch out for these caveats:
|
209
|
+
|
210
|
+
- You cannot use scopes named `with_deleted` and `only_deleted`
|
211
|
+
- You cannot use scopes named `deleted_inside_time_window`, `deleted_before_time`, `deleted_after_time` **if** your paranoid column's type is `time`
|
212
|
+
- `unscoped` will return all records, deleted or not
|
213
|
+
|
214
|
+
# Support
|
215
|
+
|
216
|
+
This gem supports the most recent versions of Rails and Ruby.
|
217
|
+
|
218
|
+
## Rails
|
219
|
+
|
220
|
+
For Rails 3.2 check the README at the [rails3.2](https://github.com/goncalossilva/rails3_acts_as_paranoid/tree/rails3.2) branch and add this to your Gemfile:
|
221
|
+
|
222
|
+
gem "rails3_acts_as_paranoid", "~>0.2.0"
|
223
|
+
|
224
|
+
For Rails 3.1 check the README at the [rails3.1](https://github.com/goncalossilva/rails3_acts_as_paranoid/tree/rails3.1) branch and add this to your Gemfile:
|
225
|
+
|
226
|
+
gem "rails3_acts_as_paranoid", "~>0.1.4"
|
227
|
+
|
228
|
+
For Rails 3.0 check the README at the [rails3.0](https://github.com/goncalossilva/rails3_acts_as_paranoid/tree/rails3.0) branch and add this to your Gemfile:
|
229
|
+
|
230
|
+
gem "rails3_acts_as_paranoid", "~>0.0.9"
|
231
|
+
|
232
|
+
|
233
|
+
## Ruby
|
234
|
+
|
235
|
+
This gem is tested on Ruby 1.9, JRuby and Rubinius (both in 1.9 mode). It *might* work fine in 1.8, but it's not officially supported.
|
236
|
+
|
237
|
+
# Acknowledgements
|
238
|
+
|
239
|
+
* To [cheerfulstoic](https://github.com/cheerfulstoic) for adding recursive recovery
|
240
|
+
* To [Jonathan Vaught](https://github.com/gravelpup) for adding paranoid validations
|
241
|
+
* To [Geoffrey Hichborn](https://github.com/phene) for improving the overral code quality and adding support for after_commit
|
242
|
+
* To [flah00](https://github.com/flah00) for adding support for STI-based associations (with :dependent)
|
243
|
+
* To [vikramdhillon](https://github.com/vikramdhillon) for the idea and
|
244
|
+
initial implementation of support for string column type
|
245
|
+
* To [Craig Walker](https://github.com/softcraft-development) for Rails 3.1 support and fixing various pending issues
|
246
|
+
* To [Charles G.](https://github.com/chuckg) for Rails 3.2 support and for making a desperately needed global code refactoring
|
247
|
+
|
248
|
+
Copyright © 2010 Gonçalo Silva, released under the MIT license
|
@@ -0,0 +1,34 @@
|
|
1
|
+
module ActsAsParanoid
|
2
|
+
module Associations
|
3
|
+
def self.included(base)
|
4
|
+
base.extend ClassMethods
|
5
|
+
class << base
|
6
|
+
alias_method_chain :belongs_to, :deleted
|
7
|
+
end
|
8
|
+
end
|
9
|
+
|
10
|
+
module ClassMethods
|
11
|
+
def belongs_to_with_deleted(target, options = {})
|
12
|
+
with_deleted = options.delete(:with_deleted)
|
13
|
+
result = belongs_to_without_deleted(target, options)
|
14
|
+
|
15
|
+
if with_deleted
|
16
|
+
result.options[:with_deleted] = with_deleted
|
17
|
+
unless method_defined? "#{target}_with_unscoped"
|
18
|
+
class_eval <<-RUBY, __FILE__, __LINE__
|
19
|
+
def #{target}_with_unscoped(*args)
|
20
|
+
association = association(:#{target})
|
21
|
+
return nil if association.options[:polymorphic] && association.klass.nil?
|
22
|
+
return #{target}_without_unscoped(*args) unless association.klass.paranoid?
|
23
|
+
association.klass.with_deleted.scoping { #{target}_without_unscoped(*args) }
|
24
|
+
end
|
25
|
+
alias_method_chain :#{target}, :unscoped
|
26
|
+
RUBY
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
result
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,184 @@
|
|
1
|
+
module ActsAsParanoid
|
2
|
+
module Core
|
3
|
+
def self.included(base)
|
4
|
+
base.extend ClassMethods
|
5
|
+
end
|
6
|
+
|
7
|
+
module ClassMethods
|
8
|
+
def self.extended(base)
|
9
|
+
base.define_callbacks :recover
|
10
|
+
end
|
11
|
+
|
12
|
+
def before_recover(method)
|
13
|
+
set_callback :recover, :before, method
|
14
|
+
end
|
15
|
+
|
16
|
+
def after_recover(method)
|
17
|
+
set_callback :recover, :after, method
|
18
|
+
end
|
19
|
+
|
20
|
+
def with_deleted
|
21
|
+
without_paranoid_default_scope
|
22
|
+
end
|
23
|
+
|
24
|
+
def only_deleted
|
25
|
+
if string_type_with_deleted_value?
|
26
|
+
without_paranoid_default_scope.where("#{paranoid_column_reference} IS ?", paranoid_configuration[:deleted_value])
|
27
|
+
else
|
28
|
+
without_paranoid_default_scope.where("#{paranoid_column_reference} IS NOT ?", nil)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def delete_all!(conditions = nil)
|
33
|
+
without_paranoid_default_scope.delete_all!(conditions)
|
34
|
+
end
|
35
|
+
|
36
|
+
def delete_all(conditions = nil)
|
37
|
+
update_all ["#{paranoid_configuration[:column]} = ?", delete_now_value], conditions
|
38
|
+
end
|
39
|
+
|
40
|
+
def paranoid_default_scope_sql
|
41
|
+
if string_type_with_deleted_value?
|
42
|
+
self.scoped.table[paranoid_column].eq(nil).
|
43
|
+
or(self.scoped.table[paranoid_column].not_eq(paranoid_configuration[:deleted_value])).
|
44
|
+
to_sql
|
45
|
+
else
|
46
|
+
self.scoped.table[paranoid_column].eq(nil).to_sql
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
def string_type_with_deleted_value?
|
51
|
+
paranoid_column_type == :string && !paranoid_configuration[:deleted_value].nil?
|
52
|
+
end
|
53
|
+
|
54
|
+
def paranoid_column
|
55
|
+
paranoid_configuration[:column].to_sym
|
56
|
+
end
|
57
|
+
|
58
|
+
def paranoid_column_type
|
59
|
+
paranoid_configuration[:column_type].to_sym
|
60
|
+
end
|
61
|
+
|
62
|
+
def dependent_associations
|
63
|
+
self.reflect_on_all_associations.select {|a| [:destroy, :delete_all].include?(a.options[:dependent]) }
|
64
|
+
end
|
65
|
+
|
66
|
+
def delete_now_value
|
67
|
+
case paranoid_configuration[:column_type]
|
68
|
+
when "time" then Time.now
|
69
|
+
when "boolean" then true
|
70
|
+
when "string" then paranoid_configuration[:deleted_value]
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
protected
|
75
|
+
|
76
|
+
def without_paranoid_default_scope
|
77
|
+
scope = self.scoped.with_default_scope
|
78
|
+
scope.where_values.delete(paranoid_default_scope_sql)
|
79
|
+
|
80
|
+
scope
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
def persisted?
|
85
|
+
!(new_record? || @destroyed)
|
86
|
+
end
|
87
|
+
|
88
|
+
def paranoid_value
|
89
|
+
self.send(self.class.paranoid_column)
|
90
|
+
end
|
91
|
+
|
92
|
+
def destroy!
|
93
|
+
with_transaction_returning_status do
|
94
|
+
run_callbacks :destroy do
|
95
|
+
destroy_dependent_associations!
|
96
|
+
# Handle composite keys, otherwise we would just use `self.class.primary_key.to_sym => self.id`.
|
97
|
+
self.class.delete_all!(Hash[[Array(self.class.primary_key), Array(self.id)].transpose])
|
98
|
+
self.paranoid_value = self.class.delete_now_value
|
99
|
+
freeze
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
def destroy
|
105
|
+
if !deleted?
|
106
|
+
with_transaction_returning_status do
|
107
|
+
run_callbacks :destroy do
|
108
|
+
# Handle composite keys, otherwise we would just use `self.class.primary_key.to_sym => self.id`.
|
109
|
+
self.class.delete_all(Hash[[Array(self.class.primary_key), Array(self.id)].transpose])
|
110
|
+
self.paranoid_value = self.class.delete_now_value
|
111
|
+
self
|
112
|
+
end
|
113
|
+
end
|
114
|
+
else
|
115
|
+
destroy!
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
def recover(options={})
|
120
|
+
options = {
|
121
|
+
:recursive => self.class.paranoid_configuration[:recover_dependent_associations],
|
122
|
+
:recovery_window => self.class.paranoid_configuration[:dependent_recovery_window]
|
123
|
+
}.merge(options)
|
124
|
+
|
125
|
+
self.class.transaction do
|
126
|
+
run_callbacks :recover do
|
127
|
+
recover_dependent_associations(options[:recovery_window], options) if options[:recursive]
|
128
|
+
|
129
|
+
self.paranoid_value = nil
|
130
|
+
self.save
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
def recover_dependent_associations(window, options)
|
136
|
+
self.class.dependent_associations.each do |reflection|
|
137
|
+
next unless reflection.klass.paranoid?
|
138
|
+
|
139
|
+
scope = reflection.klass.only_deleted
|
140
|
+
|
141
|
+
# Merge in the association's scope
|
142
|
+
scope = scope.merge(association(reflection.name).association_scope)
|
143
|
+
|
144
|
+
# We can only recover by window if both parent and dependant have a
|
145
|
+
# paranoid column type of :time.
|
146
|
+
if self.class.paranoid_column_type == :time && reflection.klass.paranoid_column_type == :time
|
147
|
+
scope = scope.merge(reflection.klass.deleted_inside_time_window(paranoid_value, window))
|
148
|
+
end
|
149
|
+
|
150
|
+
scope.each do |object|
|
151
|
+
object.recover(options)
|
152
|
+
end
|
153
|
+
end
|
154
|
+
end
|
155
|
+
|
156
|
+
def destroy_dependent_associations!
|
157
|
+
self.class.dependent_associations.each do |reflection|
|
158
|
+
next unless reflection.klass.paranoid?
|
159
|
+
|
160
|
+
scope = reflection.klass.only_deleted
|
161
|
+
|
162
|
+
# Merge in the association's scope
|
163
|
+
scope = scope.merge(association(reflection.name).association_scope)
|
164
|
+
|
165
|
+
scope.each do |object|
|
166
|
+
object.destroy!
|
167
|
+
end
|
168
|
+
end
|
169
|
+
end
|
170
|
+
|
171
|
+
def deleted?
|
172
|
+
!(paranoid_value.nil? ||
|
173
|
+
(self.class.string_type_with_deleted_value? && paranoid_value != self.class.delete_now_value))
|
174
|
+
end
|
175
|
+
|
176
|
+
alias_method :destroyed?, :deleted?
|
177
|
+
|
178
|
+
private
|
179
|
+
|
180
|
+
def paranoid_value=(value)
|
181
|
+
self.send("#{self.class.paranoid_column}=", value)
|
182
|
+
end
|
183
|
+
end
|
184
|
+
end
|