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 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
@@ -0,0 +1,11 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ /doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /tmp/
9
+
10
+ # rspec failure tracking
11
+ .rspec_status
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.travis.yml ADDED
@@ -0,0 +1,5 @@
1
+ sudo: false
2
+ language: ruby
3
+ rvm:
4
+ - 2.4.1
5
+ before_install: gem install bundler -v 1.16.1
@@ -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
@@ -0,0 +1,6 @@
1
+ source "https://rubygems.org"
2
+
3
+ git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }
4
+
5
+ # Specify your gem's dependencies in kumolus-paranoia.gemspec
6
+ gemspec
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
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
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,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -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
@@ -0,0 +1,5 @@
1
+ module Kumolus
2
+ module Paranoia
3
+ VERSION = "0.1.0"
4
+ end
5
+ 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: []