File: paranoia.rb

package info (click to toggle)
ruby-paranoia 3.0.1-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 164 kB
  • sloc: ruby: 399; makefile: 6
file content (423 lines) | stat: -rw-r--r-- 14,242 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
require 'active_record' unless defined? ActiveRecord

if [ActiveRecord::VERSION::MAJOR, ActiveRecord::VERSION::MINOR] == [5, 2] ||
   ActiveRecord::VERSION::MAJOR > 5
  require 'paranoia/active_record_5_2'
end

module Paranoia

  class << self
    # Change default values in a rails initializer
    attr_accessor :default_sentinel_value,
                  :delete_all_enabled
  end

  def self.included(klazz)
    klazz.extend Query
  end

  module Query
    def paranoid? ; true ; end

    # If you want to find all records, even those which are deleted
    def with_deleted
      if ActiveRecord::VERSION::STRING >= "4.1"
        return unscope where: paranoia_column
      end
      all.tap { |x| x.default_scoped = false }
    end

    # If you want to find only the deleted records
    def only_deleted
      if paranoia_sentinel_value.nil?
        return with_deleted.where.not(paranoia_column => paranoia_sentinel_value)
      end
      # if paranoia_sentinel_value is not null, then it is possible that
      # some deleted rows will hold a null value in the paranoia column
      # these will not match != sentinel value because "NULL != value" is
      # NULL under the sql standard
      # Scoping with the table_name is mandatory to avoid ambiguous errors when joining tables.
      scoped_quoted_paranoia_column = "#{connection.quote_table_name(self.table_name)}.#{connection.quote_column_name(paranoia_column)}"
      with_deleted.where("#{scoped_quoted_paranoia_column} IS NULL OR #{scoped_quoted_paranoia_column} != ?", paranoia_sentinel_value)
    end
    alias_method :deleted, :only_deleted

    # If you want to restore a record
    def restore(id_or_ids, opts = {})
      ids = Array(id_or_ids).flatten
      any_object_instead_of_id = ids.any? { |id| ActiveRecord::Base === id }
      if any_object_instead_of_id
        ids.map! { |id| ActiveRecord::Base === id ? id.id : id }
        ActiveSupport::Deprecation.warn("You are passing an instance of ActiveRecord::Base to `restore`. " \
                                        "Please pass the id of the object by calling `.id`")
      end
      ids.map { |id| only_deleted.find(id).restore!(opts) }
    end

    def paranoia_destroy_attributes
      {
        paranoia_column => current_time_from_proper_timezone
      }.merge(timestamp_attributes_with_current_time)
    end

    def timestamp_attributes_with_current_time
      timestamp_attributes_for_update_in_model.each_with_object({}) { |attr,hash| hash[attr] = current_time_from_proper_timezone }
    end
  end

  def paranoia_destroy
    with_transaction_returning_status do
      result = run_callbacks(:destroy) do
        @_disable_counter_cache = paranoia_destroyed?
        result = paranoia_delete
        next result unless result && ActiveRecord::VERSION::STRING >= '4.2'
        each_counter_cached_associations do |association|
          foreign_key = association.reflection.foreign_key.to_sym
          next if destroyed_by_association && destroyed_by_association.foreign_key.to_sym == foreign_key
          next unless send(association.reflection.name)
          association.decrement_counters
        end
        @_trigger_destroy_callback = true
        @_disable_counter_cache = false
        result
      end
      raise ActiveRecord::Rollback, "Not destroyed" unless paranoia_destroyed?
      result
    end || false
  end
  alias_method :destroy, :paranoia_destroy

  def paranoia_destroy!
    paranoia_destroy ||
      raise(ActiveRecord::RecordNotDestroyed.new("Failed to destroy the record", self))
  end

  def trigger_transactional_callbacks?
    super || @_trigger_destroy_callback && paranoia_destroyed? ||
      @_trigger_restore_callback && !paranoia_destroyed?
  end

  def transaction_include_any_action?(actions)
    super || actions.any? do |action|
      if action == :restore
        paranoia_after_restore_commit && @_trigger_restore_callback
      end
    end
  end

  def paranoia_delete
    raise ActiveRecord::ReadOnlyRecord, "#{self.class} is marked as readonly" if readonly?
    if persisted?
      # if a transaction exists, add the record so that after_commit
      # callbacks can be run
      add_to_transaction
      update_columns(paranoia_destroy_attributes)
    elsif !frozen?
      assign_attributes(paranoia_destroy_attributes)
    end
    self
  end
  alias_method :delete, :paranoia_delete

  def restore!(opts = {})
    self.class.transaction do
      run_callbacks(:restore) do
        recovery_window_range = get_recovery_window_range(opts)
        # Fixes a bug where the build would error because attributes were frozen.
        # This only happened on Rails versions earlier than 4.1.
        noop_if_frozen = ActiveRecord.version < Gem::Version.new("4.1")
        if within_recovery_window?(recovery_window_range) && ((noop_if_frozen && !@attributes.frozen?) || !noop_if_frozen)
          @_disable_counter_cache = !paranoia_destroyed?
          write_attribute paranoia_column, paranoia_sentinel_value
          if paranoia_after_restore_commit
            @_trigger_restore_callback = true
            add_to_transaction
          end
          update_columns(paranoia_restore_attributes)
          each_counter_cached_associations do |association|
            if send(association.reflection.name)
              association.increment_counters
            end
          end
          @_disable_counter_cache = false
        end
        restore_associated_records(recovery_window_range) if opts[:recursive]
      end
    end

    self
  ensure
    if paranoia_after_restore_commit
      @_trigger_restore_callback = false
    end
  end
  alias :restore :restore!

  def get_recovery_window_range(opts)
    return opts[:recovery_window_range] if opts[:recovery_window_range]
    return unless opts[:recovery_window]
    (deletion_time - opts[:recovery_window]..deletion_time + opts[:recovery_window])
  end

  def within_recovery_window?(recovery_window_range)
    return true unless recovery_window_range
    recovery_window_range.cover?(deletion_time)
  end

  def paranoia_destroyed?
    paranoia_column_value != paranoia_sentinel_value
  end
  alias :deleted? :paranoia_destroyed?

  def really_destroy!(update_destroy_attributes: true)
    with_transaction_returning_status do
      run_callbacks(:real_destroy) do
        @_disable_counter_cache = paranoia_destroyed?
        dependent_reflections = self.class.reflections.select do |name, reflection|
          reflection.options[:dependent] == :destroy
        end
        if dependent_reflections.any?
          dependent_reflections.each do |name, reflection|
            association_data = self.send(name)
            # has_one association can return nil
            # .paranoid? will work for both instances and classes
            next unless association_data && association_data.paranoid?
            if reflection.collection?
              next association_data.with_deleted.find_each { |record|
                record.really_destroy!(update_destroy_attributes: update_destroy_attributes)
              }
            end
            association_data.really_destroy!(update_destroy_attributes: update_destroy_attributes)
          end
        end
        update_columns(paranoia_destroy_attributes) if update_destroy_attributes
        destroy_without_paranoia
      end
    end
  end

  private

  def counter_cache_disabled?
    defined?(@_disable_counter_cache) && @_disable_counter_cache
  end

  def counter_cached_association_names
    return [] if counter_cache_disabled?
    super
  end

  def each_counter_cached_associations
    return [] if counter_cache_disabled?

    if defined?(super)
      super
    else
      counter_cached_association_names.each do |name|
        yield association(name)
      end
    end
  end

  def paranoia_restore_attributes
    {
      paranoia_column => paranoia_sentinel_value
    }.merge(self.class.timestamp_attributes_with_current_time)
  end

  delegate :paranoia_destroy_attributes, to: 'self.class'

  def paranoia_find_has_one_target(association)
    association_foreign_key = association.options[:through].present? ? association.klass.primary_key : association.foreign_key
    association_find_conditions = { association_foreign_key => self.id }
    association_find_conditions[association.type] = self.class.name if association.type

    scope = association.klass.only_deleted.where(association_find_conditions)
    scope = scope.merge(association.scope) if association.scope
    scope.first
  end

  # restore associated records that have been soft deleted when
  # we called #destroy
  def restore_associated_records(recovery_window_range = nil)
    destroyed_associations = self.class.reflect_on_all_associations.select do |association|
      association.options[:dependent] == :destroy
    end

    destroyed_associations.each do |association|
      association_data = send(association.name)

      unless association_data.nil?
        if association_data.paranoid?
          if association.collection?
            association_data.only_deleted.each do |record|
              record.restore(:recursive => true, :recovery_window_range => recovery_window_range)
            end
          else
            association_data.restore(:recursive => true, :recovery_window_range => recovery_window_range)
          end
        end
      end

      if association_data.nil? && association.macro.to_s == "has_one"
        if association.klass.paranoid?
          paranoia_find_has_one_target(association)
            .try!(:restore, recursive: true, :recovery_window_range => recovery_window_range)
        end
      end
    end

    if ActiveRecord.version.to_s > '7'
      # Method deleted in https://github.com/rails/rails/commit/dd5886d00a2d5f31ccf504c391aad93deb014eb8
      @association_cache.clear if persisted? && destroyed_associations.present?
    else
      clear_association_cache if destroyed_associations.present?
    end
  end
end

module ActiveRecord
  module Transactions
    module RestoreSupport
      def self.included(base)
        base::ACTIONS << :restore unless base::ACTIONS.include?(:restore)
      end
    end

    module ClassMethods
      def after_restore_commit(*args, &block)
        set_options_for_callbacks!(args, on: :restore)
        set_callback(:commit, :after, *args, &block)
      end
    end
  end
end

module Paranoia::Relation  
  def paranoia_delete_all
    update_all(klass.paranoia_destroy_attributes)
  end

  alias_method :delete_all, :paranoia_delete_all
end

ActiveSupport.on_load(:active_record) do
  class ActiveRecord::Base
    def self.acts_as_paranoid(options={})
      if included_modules.include?(Paranoia)
        puts "[WARN] #{self.name} is calling acts_as_paranoid more than once!"

        return
      end

      define_model_callbacks :restore, :real_destroy

      alias_method :really_destroyed?, :destroyed?
      alias_method :really_delete, :delete
      alias_method :destroy_without_paranoia, :destroy
      class << self; delegate :really_delete_all, to: :all end

      include Paranoia
      class_attribute :paranoia_column, :paranoia_sentinel_value, :paranoia_after_restore_commit,
        :delete_all_enabled

      self.paranoia_column = (options[:column] || :deleted_at).to_s
      self.paranoia_sentinel_value = options.fetch(:sentinel_value) { Paranoia.default_sentinel_value }
      self.paranoia_after_restore_commit = options.fetch(:after_restore_commit) { false }
      def self.paranoia_scope
        where(paranoia_column => paranoia_sentinel_value)
      end
      class << self; alias_method :without_deleted, :paranoia_scope end

      unless options[:without_default_scope]
        default_scope { paranoia_scope }
      end

      before_restore {
        self.class.notify_observers(:before_restore, self) if self.class.respond_to?(:notify_observers)
      }
      after_restore {
        self.class.notify_observers(:after_restore, self) if self.class.respond_to?(:notify_observers)
      }

      if paranoia_after_restore_commit
        ActiveRecord::Transactions.send(:include, ActiveRecord::Transactions::RestoreSupport)
      end

      self.delete_all_enabled = options[:delete_all_enabled] || Paranoia.delete_all_enabled

      if self.delete_all_enabled
        "#{self}::ActiveRecord_Relation".constantize.class_eval do
          alias_method :really_delete_all, :delete_all

          include Paranoia::Relation
        end
      end
    end

    # Please do not use this method in production.
    # Pretty please.
    def self.I_AM_THE_DESTROYER!
      # TODO: actually implement spelling error fixes
    puts %Q{
      Sharon: "There should be a method called I_AM_THE_DESTROYER!"
      Ryan:   "What should this method do?"
      Sharon: "It should fix all the spelling errors on the page!"
}
    end

    def self.paranoid? ; false ; end
    def paranoid? ; self.class.paranoid? ; end

    private

    def paranoia_column
      self.class.paranoia_column
    end

    def paranoia_column_value
      send(paranoia_column)
    end

    def paranoia_sentinel_value
      self.class.paranoia_sentinel_value
    end

    def deletion_time
      paranoia_column_value.acts_like?(:time) ? paranoia_column_value : deleted_at
    end
  end
end

require 'paranoia/rspec' if defined? RSpec

module ActiveRecord
  module Validations
    module UniquenessParanoiaValidator
      def build_relation(klass, *args)
        relation = super
        return relation unless klass.respond_to?(:paranoia_column)
        arel_paranoia_scope = klass.arel_table[klass.paranoia_column].eq(klass.paranoia_sentinel_value)
        if ActiveRecord::VERSION::STRING >= "5.0"
          relation.where(arel_paranoia_scope)
        else
          relation.and(arel_paranoia_scope)
        end
      end
    end

    class UniquenessValidator < ActiveModel::EachValidator
      prepend UniquenessParanoiaValidator
    end

    class AssociationNotSoftDestroyedValidator < ActiveModel::EachValidator
      def validate_each(record, attribute, value)
        # if association is soft destroyed, add an error
        if value.present? && value.paranoia_destroyed?
          record.errors.add(attribute, 'has been soft-deleted')
        end
      end
    end
  end
end