kumolus-paranoia 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +11 -0
- data/.rspec +3 -0
- data/.travis.yml +5 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +6 -0
- data/LICENSE.txt +21 -0
- data/README.md +343 -0
- data/Rakefile +6 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/kumolus-paranoia.gemspec +50 -0
- data/lib/kumolus/paranoia.rb +329 -0
- data/lib/kumolus/paranoia/rspec.rb +23 -0
- data/lib/kumolus/paranoia/version.rb +5 -0
- metadata +113 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: ac8ac01b3148db857444b0ae2b5619aa531c85aa
|
4
|
+
data.tar.gz: de73636aed4c8d9b8ee9ae5fe169d1b3fce87afa
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 99b4bfbc02d21448be898b623aebd3498fa0b323a7b05c9652238957aa0b6b63ef09aed14428ef5dd6209185217ddea068dd5e4f3b9e8f4131b43d8887b8ecd7
|
7
|
+
data.tar.gz: 497b886d71951eaa9ed6b931187cc5a7e009fbf9198247316075462cff96cab85847933b914a7c866d38fdae87766a711570d90a15eeb41d8e37fd33388f426d
|
data/.gitignore
ADDED
data/.rspec
ADDED
data/.travis.yml
ADDED
data/CODE_OF_CONDUCT.md
ADDED
@@ -0,0 +1,74 @@
|
|
1
|
+
# Contributor Covenant Code of Conduct
|
2
|
+
|
3
|
+
## Our Pledge
|
4
|
+
|
5
|
+
In the interest of fostering an open and welcoming environment, we as
|
6
|
+
contributors and maintainers pledge to making participation in our project and
|
7
|
+
our community a harassment-free experience for everyone, regardless of age, body
|
8
|
+
size, disability, ethnicity, gender identity and expression, level of experience,
|
9
|
+
nationality, personal appearance, race, religion, or sexual identity and
|
10
|
+
orientation.
|
11
|
+
|
12
|
+
## Our Standards
|
13
|
+
|
14
|
+
Examples of behavior that contributes to creating a positive environment
|
15
|
+
include:
|
16
|
+
|
17
|
+
* Using welcoming and inclusive language
|
18
|
+
* Being respectful of differing viewpoints and experiences
|
19
|
+
* Gracefully accepting constructive criticism
|
20
|
+
* Focusing on what is best for the community
|
21
|
+
* Showing empathy towards other community members
|
22
|
+
|
23
|
+
Examples of unacceptable behavior by participants include:
|
24
|
+
|
25
|
+
* The use of sexualized language or imagery and unwelcome sexual attention or
|
26
|
+
advances
|
27
|
+
* Trolling, insulting/derogatory comments, and personal or political attacks
|
28
|
+
* Public or private harassment
|
29
|
+
* Publishing others' private information, such as a physical or electronic
|
30
|
+
address, without explicit permission
|
31
|
+
* Other conduct which could reasonably be considered inappropriate in a
|
32
|
+
professional setting
|
33
|
+
|
34
|
+
## Our Responsibilities
|
35
|
+
|
36
|
+
Project maintainers are responsible for clarifying the standards of acceptable
|
37
|
+
behavior and are expected to take appropriate and fair corrective action in
|
38
|
+
response to any instances of unacceptable behavior.
|
39
|
+
|
40
|
+
Project maintainers have the right and responsibility to remove, edit, or
|
41
|
+
reject comments, commits, code, wiki edits, issues, and other contributions
|
42
|
+
that are not aligned to this Code of Conduct, or to ban temporarily or
|
43
|
+
permanently any contributor for other behaviors that they deem inappropriate,
|
44
|
+
threatening, offensive, or harmful.
|
45
|
+
|
46
|
+
## Scope
|
47
|
+
|
48
|
+
This Code of Conduct applies both within project spaces and in public spaces
|
49
|
+
when an individual is representing the project or its community. Examples of
|
50
|
+
representing a project or community include using an official project e-mail
|
51
|
+
address, posting via an official social media account, or acting as an appointed
|
52
|
+
representative at an online or offline event. Representation of a project may be
|
53
|
+
further defined and clarified by project maintainers.
|
54
|
+
|
55
|
+
## Enforcement
|
56
|
+
|
57
|
+
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
58
|
+
reported by contacting the project team at kumoas@kumolus.com. All
|
59
|
+
complaints will be reviewed and investigated and will result in a response that
|
60
|
+
is deemed necessary and appropriate to the circumstances. The project team is
|
61
|
+
obligated to maintain confidentiality with regard to the reporter of an incident.
|
62
|
+
Further details of specific enforcement policies may be posted separately.
|
63
|
+
|
64
|
+
Project maintainers who do not follow or enforce the Code of Conduct in good
|
65
|
+
faith may face temporary or permanent repercussions as determined by other
|
66
|
+
members of the project's leadership.
|
67
|
+
|
68
|
+
## Attribution
|
69
|
+
|
70
|
+
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
|
71
|
+
available at [http://contributor-covenant.org/version/1/4][version]
|
72
|
+
|
73
|
+
[homepage]: http://contributor-covenant.org
|
74
|
+
[version]: http://contributor-covenant.org/version/1/4/
|
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2018 kumoas
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,343 @@
|
|
1
|
+
# Paranoia
|
2
|
+
|
3
|
+
When your app is using Paranoia, calling `destroy` on an ActiveRecord object doesn't actually destroy the database record, but just *hides* it. Paranoia does this by setting a `deleted_at` field to the current time when you `destroy` a record, and hides it by scoping all queries on your model to only include records which do not have a `deleted_at` field.
|
4
|
+
|
5
|
+
If you wish to actually destroy an object you may call `really_destroy!`. **WARNING**: This will also *really destroy* all `dependent: :destroy` records, so please aim this method away from face when using.
|
6
|
+
|
7
|
+
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. ***See [Destroying through association callbacks](#destroying-through-association-callbacks) for clarifying examples.***
|
8
|
+
|
9
|
+
## Installation & Usage
|
10
|
+
|
11
|
+
``` ruby
|
12
|
+
gem "kumolus-paranoia"
|
13
|
+
```
|
14
|
+
Then run:
|
15
|
+
|
16
|
+
``` shell
|
17
|
+
bundle install
|
18
|
+
```
|
19
|
+
|
20
|
+
#### Run your migrations for the desired models
|
21
|
+
|
22
|
+
Run:
|
23
|
+
|
24
|
+
``` shell
|
25
|
+
bin/rails generate migration AddDeletedAtToClients deleted_at:datetime:index
|
26
|
+
```
|
27
|
+
|
28
|
+
and now you have a migration
|
29
|
+
|
30
|
+
``` ruby
|
31
|
+
class AddDeletedAtToClients < ActiveRecord::Migration
|
32
|
+
def change
|
33
|
+
add_column :clients, :deleted_at, :datetime
|
34
|
+
add_index :clients, :deleted_at
|
35
|
+
end
|
36
|
+
end
|
37
|
+
```
|
38
|
+
|
39
|
+
### Usage
|
40
|
+
|
41
|
+
#### In your model:
|
42
|
+
|
43
|
+
``` ruby
|
44
|
+
class Client < ActiveRecord::Base
|
45
|
+
acts_as_paranoid
|
46
|
+
|
47
|
+
# ...
|
48
|
+
end
|
49
|
+
```
|
50
|
+
|
51
|
+
Hey presto, it's there! Calling `destroy` will now set the `deleted_at` column:
|
52
|
+
|
53
|
+
|
54
|
+
``` ruby
|
55
|
+
>> client.deleted_at
|
56
|
+
# => nil
|
57
|
+
>> client.destroy
|
58
|
+
# => client
|
59
|
+
>> client.deleted_at
|
60
|
+
# => [current timestamp]
|
61
|
+
```
|
62
|
+
|
63
|
+
If you really want it gone *gone*, call `really_destroy!`:
|
64
|
+
|
65
|
+
``` ruby
|
66
|
+
>> client.deleted_at
|
67
|
+
# => nil
|
68
|
+
>> client.really_destroy!
|
69
|
+
# => client
|
70
|
+
```
|
71
|
+
|
72
|
+
If you want to use a column other than `deleted_at`, you can pass it as an option:
|
73
|
+
|
74
|
+
``` ruby
|
75
|
+
class Client < ActiveRecord::Base
|
76
|
+
acts_as_paranoid column: :destroyed_at
|
77
|
+
|
78
|
+
...
|
79
|
+
end
|
80
|
+
```
|
81
|
+
|
82
|
+
|
83
|
+
If you want to skip adding the default scope:
|
84
|
+
|
85
|
+
``` ruby
|
86
|
+
class Client < ActiveRecord::Base
|
87
|
+
acts_as_paranoid without_default_scope: true
|
88
|
+
|
89
|
+
...
|
90
|
+
end
|
91
|
+
```
|
92
|
+
|
93
|
+
If you want to access soft-deleted associations, override the getter method:
|
94
|
+
|
95
|
+
``` ruby
|
96
|
+
def product
|
97
|
+
Product.unscoped { super }
|
98
|
+
end
|
99
|
+
```
|
100
|
+
|
101
|
+
If you want to include associated soft-deleted objects, you can (un)scope the association:
|
102
|
+
|
103
|
+
``` ruby
|
104
|
+
class Person < ActiveRecord::Base
|
105
|
+
belongs_to :group, -> { with_deleted }
|
106
|
+
end
|
107
|
+
|
108
|
+
Person.includes(:group).all
|
109
|
+
```
|
110
|
+
|
111
|
+
If you want to find all records, even those which are deleted:
|
112
|
+
|
113
|
+
``` ruby
|
114
|
+
Client.with_deleted
|
115
|
+
```
|
116
|
+
|
117
|
+
If you want to exclude deleted records, when not able to use the default_scope (e.g. when using without_default_scope):
|
118
|
+
|
119
|
+
``` ruby
|
120
|
+
Client.without_deleted
|
121
|
+
```
|
122
|
+
|
123
|
+
If you want to find only the deleted records:
|
124
|
+
|
125
|
+
``` ruby
|
126
|
+
Client.only_deleted
|
127
|
+
```
|
128
|
+
|
129
|
+
If you want to check if a record is soft-deleted:
|
130
|
+
|
131
|
+
``` ruby
|
132
|
+
client.paranoia_destroyed?
|
133
|
+
# or
|
134
|
+
client.is_deleted?
|
135
|
+
```
|
136
|
+
|
137
|
+
If you want to restore a record:
|
138
|
+
|
139
|
+
``` ruby
|
140
|
+
Client.restore(id)
|
141
|
+
# or
|
142
|
+
client.restore
|
143
|
+
```
|
144
|
+
|
145
|
+
If you want to restore a whole bunch of records:
|
146
|
+
|
147
|
+
``` ruby
|
148
|
+
Client.restore([id1, id2, ..., idN])
|
149
|
+
```
|
150
|
+
|
151
|
+
If you want to restore a record and their dependently destroyed associated records:
|
152
|
+
|
153
|
+
``` ruby
|
154
|
+
Client.restore(id, :recursive => true)
|
155
|
+
# or
|
156
|
+
client.restore(:recursive => true)
|
157
|
+
```
|
158
|
+
|
159
|
+
If you want to restore a record and only those dependently destroyed associated records that were deleted within 2 minutes of the object upon which they depend:
|
160
|
+
|
161
|
+
``` ruby
|
162
|
+
Client.restore(id, :recursive => true. :recovery_window => 2.minutes)
|
163
|
+
# or
|
164
|
+
client.restore(:recursive => true, :recovery_window => 2.minutes)
|
165
|
+
```
|
166
|
+
|
167
|
+
Note that by default paranoia will not prevent that a soft destroyed object can't be associated with another object of a different model.
|
168
|
+
A Rails validator is provided should you require this functionality:
|
169
|
+
``` ruby
|
170
|
+
validates :some_assocation, association_not_soft_destroyed: true
|
171
|
+
```
|
172
|
+
This validator makes sure that `some_assocation` is not soft destroyed. If the object is soft destroyed the main object is rendered invalid and an validation error is added.
|
173
|
+
|
174
|
+
For more information, please look at the tests.
|
175
|
+
|
176
|
+
#### About indexes:
|
177
|
+
|
178
|
+
Beware that you should adapt all your indexes for them to work as fast as previously.
|
179
|
+
For example,
|
180
|
+
|
181
|
+
``` ruby
|
182
|
+
add_index :clients, :group_id
|
183
|
+
add_index :clients, [:group_id, :other_id]
|
184
|
+
```
|
185
|
+
|
186
|
+
should be replaced with
|
187
|
+
|
188
|
+
``` ruby
|
189
|
+
add_index :clients, :group_id, where: "deleted_at IS NULL"
|
190
|
+
add_index :clients, [:group_id, :other_id], where: "deleted_at IS NULL"
|
191
|
+
```
|
192
|
+
|
193
|
+
Of course, this is not necessary for the indexes you always use in association with `with_deleted` or `only_deleted`.
|
194
|
+
|
195
|
+
##### Unique Indexes
|
196
|
+
|
197
|
+
Because NULL != NULL in standard SQL, we can not simply create a unique index
|
198
|
+
on the deleted_at column and expect it to enforce that there only be one record
|
199
|
+
with a certain combination of values.
|
200
|
+
|
201
|
+
If your database supports them, good alternatives include partial indexes
|
202
|
+
(above) and indexes on computed columns. E.g.
|
203
|
+
|
204
|
+
``` ruby
|
205
|
+
add_index :clients, [:group_id, 'COALESCE(deleted_at, false)'], unique: true
|
206
|
+
```
|
207
|
+
|
208
|
+
If not, an alternative is to create a separate column which is maintained
|
209
|
+
alongside deleted_at for the sake of enforcing uniqueness. To that end,
|
210
|
+
paranoia makes use of two method to make its destroy and restore actions:
|
211
|
+
paranoia_restore_attributes and paranoia_destroy_attributes.
|
212
|
+
|
213
|
+
``` ruby
|
214
|
+
add_column :clients, :active, :boolean
|
215
|
+
add_index :clients, [:group_id, :active], unique: true
|
216
|
+
|
217
|
+
class Client < ActiveRecord::Base
|
218
|
+
# optionally have paranoia make use of your unique column, so that
|
219
|
+
# your lookups will benefit from the unique index
|
220
|
+
acts_as_paranoid column: :active, sentinel_value: true
|
221
|
+
|
222
|
+
def paranoia_restore_attributes
|
223
|
+
{
|
224
|
+
deleted_at: nil,
|
225
|
+
active: true
|
226
|
+
}
|
227
|
+
end
|
228
|
+
|
229
|
+
def paranoia_destroy_attributes
|
230
|
+
{
|
231
|
+
deleted_at: current_time_from_proper_timezone,
|
232
|
+
active: nil
|
233
|
+
}
|
234
|
+
end
|
235
|
+
end
|
236
|
+
```
|
237
|
+
|
238
|
+
##### Destroying through association callbacks
|
239
|
+
|
240
|
+
When dealing with `dependent: :destroy` associations and `acts_as_paranoid`, it's important to remember that whatever method is called on the parent model will be called on the child model. For example, given both models of an association have `acts_as_paranoid` defined:
|
241
|
+
|
242
|
+
``` ruby
|
243
|
+
class Client < ActiveRecord::Base
|
244
|
+
acts_as_paranoid
|
245
|
+
|
246
|
+
has_many :emails, dependent: :destroy
|
247
|
+
end
|
248
|
+
|
249
|
+
class Email < ActiveRecord::Base
|
250
|
+
acts_as_paranoid
|
251
|
+
|
252
|
+
belongs_to :client
|
253
|
+
end
|
254
|
+
```
|
255
|
+
|
256
|
+
When we call `destroy` on the parent `client`, it will call `destroy` on all of its associated children `emails`:
|
257
|
+
|
258
|
+
``` ruby
|
259
|
+
>> client.emails.count
|
260
|
+
# => 5
|
261
|
+
>> client.destroy
|
262
|
+
# => client
|
263
|
+
>> client.deleted_at
|
264
|
+
# => [current timestamp]
|
265
|
+
>> Email.where(client_id: client.id).count
|
266
|
+
# => 0
|
267
|
+
>> Email.with_deleted.where(client_id: client.id).count
|
268
|
+
# => 5
|
269
|
+
```
|
270
|
+
|
271
|
+
Similarly, when we call `really_destroy!` on the parent `client`, then each child `email` will also have `really_destroy!` called:
|
272
|
+
|
273
|
+
``` ruby
|
274
|
+
>> client.emails.count
|
275
|
+
# => 5
|
276
|
+
>> client.id
|
277
|
+
# => 12345
|
278
|
+
>> client.really_destroy!
|
279
|
+
# => client
|
280
|
+
>> Client.find 12345
|
281
|
+
# => ActiveRecord::RecordNotFound
|
282
|
+
>> Email.with_deleted.where(client_id: client.id).count
|
283
|
+
# => 0
|
284
|
+
```
|
285
|
+
|
286
|
+
However, if the child model `Email` does not have `acts_as_paranoid` set, then calling `destroy` on the parent `client` will also call `destroy` on each child `email`, thereby actually destroying them:
|
287
|
+
|
288
|
+
``` ruby
|
289
|
+
class Client < ActiveRecord::Base
|
290
|
+
acts_as_paranoid
|
291
|
+
|
292
|
+
has_many :emails, dependent: :destroy
|
293
|
+
end
|
294
|
+
|
295
|
+
class Email < ActiveRecord::Base
|
296
|
+
belongs_to :client
|
297
|
+
end
|
298
|
+
|
299
|
+
>> client.emails.count
|
300
|
+
# => 5
|
301
|
+
>> client.destroy
|
302
|
+
# => client
|
303
|
+
>> Email.where(client_id: client.id).count
|
304
|
+
# => 0
|
305
|
+
>> Email.with_deleted.where(client_id: client.id).count
|
306
|
+
# => NoMethodError: undefined method `with_deleted' for #<Class:0x0123456>
|
307
|
+
```
|
308
|
+
|
309
|
+
## Acts As Paranoid Migration
|
310
|
+
|
311
|
+
You can replace the older `acts_as_paranoid` methods as follows:
|
312
|
+
|
313
|
+
| Old Syntax | New Syntax |
|
314
|
+
|:-------------------------- |:------------------------------ |
|
315
|
+
|`find_with_deleted(:all)` | `Client.with_deleted` |
|
316
|
+
|`find_with_deleted(:first)` | `Client.with_deleted.first` |
|
317
|
+
|`find_with_deleted(id)` | `Client.with_deleted.find(id)` |
|
318
|
+
|
319
|
+
|
320
|
+
The `recover` method in `acts_as_paranoid` runs `update` callbacks. Paranoia's
|
321
|
+
`restore` method does not do this.
|
322
|
+
|
323
|
+
## Callbacks
|
324
|
+
|
325
|
+
Paranoia provides several callbacks. It triggers `destroy` callback when the record is marked as deleted and `real_destroy` when the record is completely removed from database. It also calls `restore` callback when the record is restored via paranoia
|
326
|
+
|
327
|
+
For example if you want to index your records in some search engine you can go like this:
|
328
|
+
|
329
|
+
```ruby
|
330
|
+
class Product < ActiveRecord::Base
|
331
|
+
acts_as_paranoid
|
332
|
+
|
333
|
+
after_destroy :update_document_in_search_engine
|
334
|
+
after_restore :update_document_in_search_engine
|
335
|
+
after_real_destroy :remove_document_from_search_engine
|
336
|
+
end
|
337
|
+
```
|
338
|
+
|
339
|
+
You can use these events just like regular Rails callbacks with before, after and around hooks.
|
340
|
+
|
341
|
+
## License
|
342
|
+
|
343
|
+
This gem is released under the MIT license.
|
data/Rakefile
ADDED
data/bin/console
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "bundler/setup"
|
4
|
+
require "kumolus/paranoia"
|
5
|
+
|
6
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
7
|
+
# with your gem easier. You can also use a different console, if you like.
|
8
|
+
|
9
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
10
|
+
# require "pry"
|
11
|
+
# Pry.start
|
12
|
+
|
13
|
+
require "irb"
|
14
|
+
IRB.start(__FILE__)
|
data/bin/setup
ADDED
@@ -0,0 +1,50 @@
|
|
1
|
+
|
2
|
+
lib = File.expand_path("../lib", __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require "kumolus/paranoia/version"
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "kumolus-paranoia"
|
8
|
+
spec.version = Kumolus::Paranoia::VERSION
|
9
|
+
spec.authors = ["kumoas"]
|
10
|
+
spec.email = ["kumoas@kumolus.com"]
|
11
|
+
|
12
|
+
spec.summary = "This gem is use for soft delete of active record object"
|
13
|
+
spec.description = <<-DSC
|
14
|
+
You would use this Paranoia gem if you
|
15
|
+
wished that when you called destroy on an Active Record object that it
|
16
|
+
didn't actually destroy it, but just "hide" the record. Paranoia does this
|
17
|
+
by setting a deleted_at field to the current time when you destroy a record,
|
18
|
+
and hides it by scoping all queries on your model to only include records
|
19
|
+
which do not have a deleted_at field.
|
20
|
+
DSC
|
21
|
+
spec.homepage = "https://github.com/kumoas/kumolus-paranoia"
|
22
|
+
spec.license = "MIT"
|
23
|
+
|
24
|
+
# Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host'
|
25
|
+
# to allow pushing to a single host or delete this section to allow pushing to any host.
|
26
|
+
if spec.respond_to?(:metadata)
|
27
|
+
spec.metadata["allowed_push_host"] = "https://rubygems.org/"
|
28
|
+
else
|
29
|
+
raise "RubyGems 2.0 or newer is required to protect against " \
|
30
|
+
"public gem pushes."
|
31
|
+
end
|
32
|
+
|
33
|
+
spec.files = `git ls-files -z`.split("\x0").reject do |f|
|
34
|
+
f.match(%r{^(test|spec|features)/})
|
35
|
+
end
|
36
|
+
|
37
|
+
spec.required_rubygems_version = ">= 1.3.6"
|
38
|
+
|
39
|
+
spec.required_ruby_version = '>= 2.0'
|
40
|
+
spec.add_dependency 'activerecord', '>= 4.0', '>= 5.1'
|
41
|
+
|
42
|
+
spec.bindir = "exe"
|
43
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
44
|
+
spec.require_paths = ["lib"]
|
45
|
+
|
46
|
+
spec.add_development_dependency "bundler", "~> 1.16"
|
47
|
+
spec.add_development_dependency "rake", "~> 10.0"
|
48
|
+
|
49
|
+
spec.require_path = 'lib'
|
50
|
+
end
|
@@ -0,0 +1,329 @@
|
|
1
|
+
require "kumolus/paranoia/version"
|
2
|
+
require 'active_record' unless defined? ActiveRecord
|
3
|
+
|
4
|
+
module Kumolus
|
5
|
+
module Paranoia
|
6
|
+
@@default_sentinel_value = nil
|
7
|
+
|
8
|
+
# Change default_sentinel_value in a rails initializer
|
9
|
+
def self.default_sentinel_value=(val)
|
10
|
+
@@default_sentinel_value = val
|
11
|
+
end
|
12
|
+
|
13
|
+
def self.default_sentinel_value
|
14
|
+
@@default_sentinel_value
|
15
|
+
end
|
16
|
+
|
17
|
+
def self.included(klazz)
|
18
|
+
klazz.extend Query
|
19
|
+
klazz.extend Callbacks
|
20
|
+
end
|
21
|
+
|
22
|
+
module Query
|
23
|
+
def paranoid? ; true ; end
|
24
|
+
|
25
|
+
def with_deleted
|
26
|
+
if ActiveRecord::VERSION::STRING >= "4.1"
|
27
|
+
return unscope where: paranoia_column
|
28
|
+
end
|
29
|
+
all.tap { |x| x.default_scoped = false }
|
30
|
+
end
|
31
|
+
|
32
|
+
def only_deleted
|
33
|
+
if paranoia_sentinel_value.nil?
|
34
|
+
return with_deleted.where.not(paranoia_column => paranoia_sentinel_value)
|
35
|
+
end
|
36
|
+
# if paranoia_sentinel_value is not null, then it is possible that
|
37
|
+
# some deleted rows will hold a null value in the paranoia column
|
38
|
+
# these will not match != sentinel value because "NULL != value" is
|
39
|
+
# NULL under the sql standard
|
40
|
+
# Scoping with the table_name is mandatory to avoid ambiguous errors when joining tables.
|
41
|
+
scoped_quoted_paranoia_column = "#{self.table_name}.#{connection.quote_column_name(paranoia_column)}"
|
42
|
+
with_deleted.where("#{scoped_quoted_paranoia_column} IS NULL OR #{scoped_quoted_paranoia_column} != ?", paranoia_sentinel_value)
|
43
|
+
end
|
44
|
+
alias_method :deleted, :only_deleted
|
45
|
+
|
46
|
+
def restore(id_or_ids, opts = {})
|
47
|
+
ids = Array(id_or_ids).flatten
|
48
|
+
any_object_instead_of_id = ids.any? { |id| ActiveRecord::Base === id }
|
49
|
+
if any_object_instead_of_id
|
50
|
+
ids.map! { |id| ActiveRecord::Base === id ? id.id : id }
|
51
|
+
ActiveSupport::Deprecation.warn("You are passing an instance of ActiveRecord::Base to `restore`. " \
|
52
|
+
"Please pass the id of the object by calling `.id`")
|
53
|
+
end
|
54
|
+
ids.map { |id| only_deleted.find(id).restore!(opts) }
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
module Callbacks
|
59
|
+
def self.extended(klazz)
|
60
|
+
[:restore, :real_destroy].each do |callback_name|
|
61
|
+
klazz.define_callbacks callback_name
|
62
|
+
|
63
|
+
klazz.define_singleton_method("before_#{callback_name}") do |*args, &block|
|
64
|
+
set_callback(callback_name, :before, *args, &block)
|
65
|
+
end
|
66
|
+
|
67
|
+
klazz.define_singleton_method("around_#{callback_name}") do |*args, &block|
|
68
|
+
set_callback(callback_name, :around, *args, &block)
|
69
|
+
end
|
70
|
+
|
71
|
+
klazz.define_singleton_method("after_#{callback_name}") do |*args, &block|
|
72
|
+
set_callback(callback_name, :after, *args, &block)
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
def destroy
|
79
|
+
transaction do
|
80
|
+
run_callbacks(:destroy) do
|
81
|
+
@_disable_counter_cache = deleted?
|
82
|
+
result = delete
|
83
|
+
next result unless result && ActiveRecord::VERSION::STRING >= '4.2'
|
84
|
+
each_counter_cached_associations do |association|
|
85
|
+
foreign_key = association.reflection.foreign_key.to_sym
|
86
|
+
next if destroyed_by_association && destroyed_by_association.foreign_key.to_sym == foreign_key
|
87
|
+
next unless send(association.reflection.name)
|
88
|
+
association.decrement_counters
|
89
|
+
end
|
90
|
+
@_disable_counter_cache = false
|
91
|
+
result
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
def delete
|
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
|
107
|
+
end
|
108
|
+
|
109
|
+
def restore!(opts = {})
|
110
|
+
self.class.transaction do
|
111
|
+
run_callbacks(:restore) do
|
112
|
+
recovery_window_range = get_recovery_window_range(opts)
|
113
|
+
# Fixes a bug where the build would error because attributes were frozen.
|
114
|
+
# This only happened on Rails versions earlier than 4.1.
|
115
|
+
noop_if_frozen = ActiveRecord.version < Gem::Version.new("4.1")
|
116
|
+
if within_recovery_window?(recovery_window_range) && ((noop_if_frozen && !@attributes.frozen?) || !noop_if_frozen)
|
117
|
+
@_disable_counter_cache = !deleted?
|
118
|
+
write_attribute paranoia_column, paranoia_sentinel_value
|
119
|
+
update_columns(paranoia_restore_attributes)
|
120
|
+
each_counter_cached_associations do |association|
|
121
|
+
if send(association.reflection.name)
|
122
|
+
association.increment_counters
|
123
|
+
end
|
124
|
+
end
|
125
|
+
@_disable_counter_cache = false
|
126
|
+
end
|
127
|
+
restore_associated_records(recovery_window_range) if opts[:recursive]
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
self
|
132
|
+
end
|
133
|
+
alias :restore :restore!
|
134
|
+
|
135
|
+
def get_recovery_window_range(opts)
|
136
|
+
return opts[:recovery_window_range] if opts[:recovery_window_range]
|
137
|
+
return unless opts[:recovery_window]
|
138
|
+
(deleted_at - opts[:recovery_window]..deleted_at + opts[:recovery_window])
|
139
|
+
end
|
140
|
+
|
141
|
+
def within_recovery_window?(recovery_window_range)
|
142
|
+
return true unless recovery_window_range
|
143
|
+
recovery_window_range.cover?(deleted_at)
|
144
|
+
end
|
145
|
+
|
146
|
+
def paranoia_destroyed?
|
147
|
+
send(paranoia_column) != paranoia_sentinel_value
|
148
|
+
end
|
149
|
+
alias :is_deleted? :paranoia_destroyed?
|
150
|
+
|
151
|
+
def really_destroy!
|
152
|
+
transaction do
|
153
|
+
run_callbacks(:real_destroy) do
|
154
|
+
@_disable_counter_cache = deleted?
|
155
|
+
dependent_reflections = self.class.reflections.select do |name, reflection|
|
156
|
+
reflection.options[:dependent] == :destroy
|
157
|
+
end
|
158
|
+
if dependent_reflections.any?
|
159
|
+
dependent_reflections.each do |name, reflection|
|
160
|
+
association_data = self.send(name)
|
161
|
+
# has_one association can return nil
|
162
|
+
# .paranoid? will work for both instances and classes
|
163
|
+
next unless association_data && association_data.paranoid?
|
164
|
+
if reflection.collection?
|
165
|
+
next association_data.with_deleted.each(&:really_destroy!)
|
166
|
+
end
|
167
|
+
association_data.really_destroy!
|
168
|
+
end
|
169
|
+
end
|
170
|
+
write_attribute(paranoia_column, current_time_from_proper_timezone)
|
171
|
+
destroy_without_paranoia
|
172
|
+
end
|
173
|
+
end
|
174
|
+
end
|
175
|
+
|
176
|
+
private
|
177
|
+
|
178
|
+
def each_counter_cached_associations
|
179
|
+
!@_disable_counter_cache && defined?(super) ? super : []
|
180
|
+
end
|
181
|
+
|
182
|
+
def paranoia_restore_attributes
|
183
|
+
{
|
184
|
+
paranoia_column => paranoia_sentinel_value
|
185
|
+
}.merge(timestamp_attributes_with_current_time)
|
186
|
+
end
|
187
|
+
|
188
|
+
def paranoia_destroy_attributes
|
189
|
+
{
|
190
|
+
paranoia_column => current_time_from_proper_timezone
|
191
|
+
}.merge(timestamp_attributes_with_current_time)
|
192
|
+
end
|
193
|
+
|
194
|
+
def timestamp_attributes_with_current_time
|
195
|
+
timestamp_attributes_for_update_in_model.each_with_object({}) { |attr,hash| hash[attr] = current_time_from_proper_timezone }
|
196
|
+
end
|
197
|
+
|
198
|
+
# restore associated records that have been soft deleted when
|
199
|
+
# we called #destroy
|
200
|
+
def restore_associated_records(recovery_window_range = nil)
|
201
|
+
destroyed_associations = self.class.reflect_on_all_associations.select do |association|
|
202
|
+
association.options[:dependent] == :destroy
|
203
|
+
end
|
204
|
+
|
205
|
+
destroyed_associations.each do |association|
|
206
|
+
association_data = send(association.name)
|
207
|
+
|
208
|
+
unless association_data.nil?
|
209
|
+
if association_data.paranoid?
|
210
|
+
if association.collection?
|
211
|
+
association_data.only_deleted.each do |record|
|
212
|
+
record.restore(:recursive => true, :recovery_window_range => recovery_window_range)
|
213
|
+
end
|
214
|
+
else
|
215
|
+
association_data.restore(:recursive => true, :recovery_window_range => recovery_window_range)
|
216
|
+
end
|
217
|
+
end
|
218
|
+
end
|
219
|
+
|
220
|
+
if association_data.nil? && association.macro.to_s == "has_one"
|
221
|
+
association_class_name = association.klass.name
|
222
|
+
association_foreign_key = association.foreign_key
|
223
|
+
|
224
|
+
if association.type
|
225
|
+
association_polymorphic_type = association.type
|
226
|
+
association_find_conditions = { association_polymorphic_type => self.class.name.to_s, association_foreign_key => self.id }
|
227
|
+
else
|
228
|
+
association_find_conditions = { association_foreign_key => self.id }
|
229
|
+
end
|
230
|
+
|
231
|
+
association_class = association_class_name.constantize
|
232
|
+
if association_class.paranoid?
|
233
|
+
association_class.only_deleted.where(association_find_conditions).first
|
234
|
+
.try!(:restore, recursive: true, :recovery_window_range => recovery_window_range)
|
235
|
+
end
|
236
|
+
end
|
237
|
+
end
|
238
|
+
|
239
|
+
clear_association_cache if destroyed_associations.present?
|
240
|
+
end
|
241
|
+
end
|
242
|
+
|
243
|
+
ActiveSupport.on_load(:active_record) do
|
244
|
+
class ActiveRecord::Base
|
245
|
+
def self.acts_as_paranoid(options={})
|
246
|
+
alias_method :really_destroyed?, :destroyed?
|
247
|
+
alias_method :really_delete, :delete
|
248
|
+
alias_method :destroy_without_paranoia, :destroy
|
249
|
+
|
250
|
+
include Paranoia
|
251
|
+
class_attribute :paranoia_column, :paranoia_sentinel_value
|
252
|
+
|
253
|
+
self.paranoia_column = (options[:column] || :deleted_at).to_s
|
254
|
+
self.paranoia_sentinel_value = options.fetch(:sentinel_value) { Paranoia.default_sentinel_value }
|
255
|
+
def self.paranoia_scope
|
256
|
+
where(paranoia_column => paranoia_sentinel_value)
|
257
|
+
end
|
258
|
+
class << self; alias_method :without_deleted, :paranoia_scope end
|
259
|
+
|
260
|
+
unless options[:without_default_scope]
|
261
|
+
default_scope { paranoia_scope }
|
262
|
+
end
|
263
|
+
|
264
|
+
before_restore {
|
265
|
+
self.class.notify_observers(:before_restore, self) if self.class.respond_to?(:notify_observers)
|
266
|
+
}
|
267
|
+
after_restore {
|
268
|
+
self.class.notify_observers(:after_restore, self) if self.class.respond_to?(:notify_observers)
|
269
|
+
}
|
270
|
+
end
|
271
|
+
|
272
|
+
# Please do not use this method in production.
|
273
|
+
# Pretty please.
|
274
|
+
def self.I_AM_THE_DESTROYER!
|
275
|
+
# TODO: actually implement spelling error fixes
|
276
|
+
puts %Q{
|
277
|
+
Sharon: "There should be a method called I_AM_THE_DESTROYER!"
|
278
|
+
Ryan: "What should this method do?"
|
279
|
+
Sharon: "It should fix all the spelling errors on the page!"
|
280
|
+
}
|
281
|
+
end
|
282
|
+
|
283
|
+
def self.paranoid? ; false ; end
|
284
|
+
def paranoid? ; self.class.paranoid? ; end
|
285
|
+
|
286
|
+
private
|
287
|
+
|
288
|
+
def paranoia_column
|
289
|
+
self.class.paranoia_column
|
290
|
+
end
|
291
|
+
|
292
|
+
def paranoia_sentinel_value
|
293
|
+
self.class.paranoia_sentinel_value
|
294
|
+
end
|
295
|
+
end
|
296
|
+
end
|
297
|
+
|
298
|
+
require 'kumolus/paranoia/rspec' if defined? RSpec
|
299
|
+
|
300
|
+
module ActiveRecord
|
301
|
+
module Validations
|
302
|
+
module UniquenessParanoiaValidator
|
303
|
+
def build_relation(klass, *args)
|
304
|
+
relation = super
|
305
|
+
return relation unless klass.respond_to?(:paranoia_column)
|
306
|
+
arel_paranoia_scope = klass.arel_table[klass.paranoia_column].eq(klass.paranoia_sentinel_value)
|
307
|
+
if ActiveRecord::VERSION::STRING >= "5.0"
|
308
|
+
relation.where(arel_paranoia_scope)
|
309
|
+
else
|
310
|
+
relation.and(arel_paranoia_scope)
|
311
|
+
end
|
312
|
+
end
|
313
|
+
end
|
314
|
+
|
315
|
+
class UniquenessValidator < ActiveModel::EachValidator
|
316
|
+
prepend UniquenessParanoiaValidator
|
317
|
+
end
|
318
|
+
|
319
|
+
class AssociationNotSoftDestroyedValidator < ActiveModel::EachValidator
|
320
|
+
def validate_each(record, attribute, value)
|
321
|
+
# if association is soft destroyed, add an error
|
322
|
+
if value.present? && value.deleted?
|
323
|
+
record.errors[attribute] << 'has been soft-deleted'
|
324
|
+
end
|
325
|
+
end
|
326
|
+
end
|
327
|
+
end
|
328
|
+
end
|
329
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
require 'rspec/expectations'
|
2
|
+
|
3
|
+
# Validate the subject's class did call "acts_as_paranoid"
|
4
|
+
RSpec::Matchers.define :act_as_paranoid do
|
5
|
+
match { |subject| subject.class.ancestors.include?(Paranoia) }
|
6
|
+
|
7
|
+
failure_message_proc = lambda do
|
8
|
+
"expected #{subject.class} to use `acts_as_paranoid`"
|
9
|
+
end
|
10
|
+
|
11
|
+
failure_message_when_negated_proc = lambda do
|
12
|
+
"expected #{subject.class} not to use `acts_as_paranoid`"
|
13
|
+
end
|
14
|
+
|
15
|
+
if respond_to?(:failure_message_when_negated)
|
16
|
+
failure_message(&failure_message_proc)
|
17
|
+
failure_message_when_negated(&failure_message_when_negated_proc)
|
18
|
+
else
|
19
|
+
# RSpec 2 compatibility:
|
20
|
+
failure_message_for_should(&failure_message_proc)
|
21
|
+
failure_message_for_should_not(&failure_message_when_negated_proc)
|
22
|
+
end
|
23
|
+
end
|
metadata
ADDED
@@ -0,0 +1,113 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: kumolus-paranoia
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- kumoas
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2018-11-13 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: activerecord
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '4.0'
|
20
|
+
- - ">="
|
21
|
+
- !ruby/object:Gem::Version
|
22
|
+
version: '5.1'
|
23
|
+
type: :runtime
|
24
|
+
prerelease: false
|
25
|
+
version_requirements: !ruby/object:Gem::Requirement
|
26
|
+
requirements:
|
27
|
+
- - ">="
|
28
|
+
- !ruby/object:Gem::Version
|
29
|
+
version: '4.0'
|
30
|
+
- - ">="
|
31
|
+
- !ruby/object:Gem::Version
|
32
|
+
version: '5.1'
|
33
|
+
- !ruby/object:Gem::Dependency
|
34
|
+
name: bundler
|
35
|
+
requirement: !ruby/object:Gem::Requirement
|
36
|
+
requirements:
|
37
|
+
- - "~>"
|
38
|
+
- !ruby/object:Gem::Version
|
39
|
+
version: '1.16'
|
40
|
+
type: :development
|
41
|
+
prerelease: false
|
42
|
+
version_requirements: !ruby/object:Gem::Requirement
|
43
|
+
requirements:
|
44
|
+
- - "~>"
|
45
|
+
- !ruby/object:Gem::Version
|
46
|
+
version: '1.16'
|
47
|
+
- !ruby/object:Gem::Dependency
|
48
|
+
name: rake
|
49
|
+
requirement: !ruby/object:Gem::Requirement
|
50
|
+
requirements:
|
51
|
+
- - "~>"
|
52
|
+
- !ruby/object:Gem::Version
|
53
|
+
version: '10.0'
|
54
|
+
type: :development
|
55
|
+
prerelease: false
|
56
|
+
version_requirements: !ruby/object:Gem::Requirement
|
57
|
+
requirements:
|
58
|
+
- - "~>"
|
59
|
+
- !ruby/object:Gem::Version
|
60
|
+
version: '10.0'
|
61
|
+
description: |2
|
62
|
+
You would use this Paranoia gem if you
|
63
|
+
wished that when you called destroy on an Active Record object that it
|
64
|
+
didn't actually destroy it, but just "hide" the record. Paranoia does this
|
65
|
+
by setting a deleted_at field to the current time when you destroy a record,
|
66
|
+
and hides it by scoping all queries on your model to only include records
|
67
|
+
which do not have a deleted_at field.
|
68
|
+
email:
|
69
|
+
- kumoas@kumolus.com
|
70
|
+
executables: []
|
71
|
+
extensions: []
|
72
|
+
extra_rdoc_files: []
|
73
|
+
files:
|
74
|
+
- ".gitignore"
|
75
|
+
- ".rspec"
|
76
|
+
- ".travis.yml"
|
77
|
+
- CODE_OF_CONDUCT.md
|
78
|
+
- Gemfile
|
79
|
+
- LICENSE.txt
|
80
|
+
- README.md
|
81
|
+
- Rakefile
|
82
|
+
- bin/console
|
83
|
+
- bin/setup
|
84
|
+
- kumolus-paranoia.gemspec
|
85
|
+
- lib/kumolus/paranoia.rb
|
86
|
+
- lib/kumolus/paranoia/rspec.rb
|
87
|
+
- lib/kumolus/paranoia/version.rb
|
88
|
+
homepage: https://github.com/kumoas/kumolus-paranoia
|
89
|
+
licenses:
|
90
|
+
- MIT
|
91
|
+
metadata:
|
92
|
+
allowed_push_host: https://rubygems.org/
|
93
|
+
post_install_message:
|
94
|
+
rdoc_options: []
|
95
|
+
require_paths:
|
96
|
+
- lib
|
97
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
98
|
+
requirements:
|
99
|
+
- - ">="
|
100
|
+
- !ruby/object:Gem::Version
|
101
|
+
version: '2.0'
|
102
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
103
|
+
requirements:
|
104
|
+
- - ">="
|
105
|
+
- !ruby/object:Gem::Version
|
106
|
+
version: 1.3.6
|
107
|
+
requirements: []
|
108
|
+
rubyforge_project:
|
109
|
+
rubygems_version: 2.6.11
|
110
|
+
signing_key:
|
111
|
+
specification_version: 4
|
112
|
+
summary: This gem is use for soft delete of active record object
|
113
|
+
test_files: []
|