Skip to content

Instantly share code, notes, and snippets.

@Envek
Created July 11, 2025 11:51
Show Gist options
  • Save Envek/ff4423022de00ba7611df865a34d7b78 to your computer and use it in GitHub Desktop.
Save Envek/ff4423022de00ba7611df865a34d7b78 to your computer and use it in GitHub Desktop.
Opt-in ability to soft-delete records in future for gem paranoia

Paranoia in the future

Opt-in ability to soft-delete records in future for gem paranoia

The problem

By default Paranoia treats any record with non-null deleted_at as already deleted record no matter actual value of the attribute. And sometimes you may want to mark records for automatic deletion in the future.

See rubysherpas/paranoia#480

The solution

Check not only the paranoia_column is nil, but also check its value in relation to the current timestamp.

Usage

  1. Put paranoia_in_the_future.rb file into your Ruby on Rails app app/models/concerns directory

  2. Include the ParanoiaInTheFuture module to model classes that need to enable this behavior:

  3. Call acts_as_paranoid as always

class FutureProof < ApplicationRecord
  include ParanoiaInTheFuture
  
  acts_as_paranoid
end

Notes

  1. Database query performance may decrease due to ORing of two conditions:

    SELECT *
    FROM future_proofs
    WHERE deleted_at IS NULL
       OR deleted_at > CURRENT_TIMESTAMP

    Your existing indexes may become insufficient. Check your query plans on production-like environment first!

  2. Paranoia callbacks will be fired immediately and won't be postponed.

  3. To ensure that results are consistent in long-running transactions, current value of standard SQL 1992 CURRENT_TIMESTAMP function is used for both querying and soft-deleting records, though this adds a bit of complexity and fragility (as ActiveRecord doesn't allow to use SQL literals in the update_columns method I didn't want to change the implementation too much from the original).

# Modify acts_as_paranoid to allow for postponed soft deletion (in the future)
# Obviously it works only with timestamp columns (though this is the default)
module ParanoiaInTheFuture
extend ActiveSupport::Concern
class_methods do
# rubocop:disable Lint/NestedMethodDefinition
def acts_as_paranoid(without_default_scope: false, **paranoia_options)
super(**paranoia_options, without_default_scope: true)
default_scope { paranoia_scope } unless without_default_scope
# paranoia dynamically defines these methods on `acts_as_paranoid` invocation
# so we need to do the same. See https://github.com/rubysherpas/paranoia/blob/12c851ce00b06cbbeffceda1853d8d1808584522/lib/paranoia.rb#L328-L331
# Use CURRENT_TIMESTAMP to make repeatable query results to be stable during an open transaction
def self.paranoia_scope
where(paranoia_column => nil).or(where(arel_table[paranoia_column].gt(Arel.sql("CURRENT_TIMESTAMP"))))
end
class << self; alias_method :without_deleted, :paranoia_scope end
def self.only_deleted
unscope(where: paranoia_column).where.not(paranoia_column => nil).where(arel_table[paranoia_column].lteq(Arel.sql("CURRENT_TIMESTAMP")))
end
class << self; alias_method :deleted, :only_deleted end
def self.pending_deletion
unscope(where: paranoia_column).where(arel_table[paranoia_column].gt(Arel.sql("CURRENT_TIMESTAMP")))
end
# Use value of CURRENT_TIMESTAMP to match scope behavior and ensure that after record deletion
# it will be selected as deleted by queries during the same transaction
def self.paranoia_destroy_attributes
{
paranoia_column => paranoia_now,
}.merge(timestamp_attributes_with_current_time)
end
# Use value of CURRENT_TIMESTAMP if we are in a real transaction
# As every RealTransaction will be a new object, no need to clean up the cache
def self.paranoia_now
return current_time_from_proper_timezone unless connection.transaction_open? && connection.current_transaction.joinable?
transaction_now = connection.current_transaction.instance_variable_get(:@paranoia_now)
return transaction_now if transaction_now
transaction_now = connection.execute("SELECT CURRENT_TIMESTAMP AS now").first.dig("now")
connection.current_transaction.instance_variable_set(:@paranoia_now, transaction_now)
transaction_now
end
end
# rubocop:enable Lint/NestedMethodDefinition
end
def paranoia_destroyed?
paranoia_column_value.present? && paranoia_column_value <= Time.current
end
def pending_deletion?
paranoia_column_value.present? && paranoia_column_value > Time.current
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment