Soft Delete Records In Rails Without A Gem Using Discarded_at
Some records should disappear from the application without leaving the database — orders referenced by invoices, users referenced by audit logs. A nullable timestamp column and a default scope gives you soft deletion in about fifteen lines.
Description
Add a nullable discarded_at timestamp. “Discarding” sets it to now; “undiscarding” sets it back to nil. Scopes named kept and discarded express the two states, and a default_scope keeps discarded rows out of ordinary queries.
Use a partial unique index so a discarded record doesn’t block a new one from reusing the same email or slug — this is the detail most hand-rolled soft deletes miss.
Keep really_destroy! available for GDPR deletion requests and for cleaning up test data.
Sample input:
user.discard!
User.count # excludes the discarded row
User.with_discarded.count
Sample Output:
# => discarded_at set; row still present in the table
Answer
# Migration
class AddDiscardedAtToUsers < ActiveRecord::Migration[7.1]
disable_ddl_transaction!
def change
add_column :users, :discarded_at, :datetime
add_index :users, :discarded_at, algorithm: :concurrently
# Uniqueness that ignores discarded rows
remove_index :users, :email
add_index :users, :email,
unique: true,
where: "discarded_at IS NULL",
algorithm: :concurrently
end
end
# Concern
module Discardable
extend ActiveSupport::Concern
included do
scope :kept, -> { where(discarded_at: nil) }
scope :discarded, -> { where.not(discarded_at: nil) }
default_scope { kept }
end
class_methods do
def with_discarded = unscope(where: :discarded_at)
end
def discard! = update!(discarded_at: Time.current)
def undiscard! = update!(discarded_at: nil)
def discarded? = discarded_at.present?
def really_destroy! = self.class.with_discarded.where(id: id).delete_all
end
class User < ApplicationRecord
include Discardable
end
Check viewARU - Brand Newsletter!
Newsletter to DEVs by DEVs - boost your Personal Brand & career! 🚀