class AddSoftDeleteToDocuments < ActiveRecord::Migration[7.0]
def change
add_column :documents, :deleted_at, :datetime
add_index :documents, :deleted_at
# Replace the global unique index with one scoped to live rows,
# so a slug can be reused after a record is soft-deleted.
remove_index :documents, :slug
add_index :documents, :slug,
unique: true,
where: "deleted_at IS NULL",
name: "index_documents_on_slug_when_live"
end
end
module SoftDeletable
extend ActiveSupport::Concern
included do
default_scope { where(deleted_at: nil) }
scope :with_deleted, -> { unscope(where: :deleted_at) }
scope :only_deleted, -> { with_deleted.where.not(deleted_at: nil) }
end
def soft_delete
return true if deleted?
update_column(:deleted_at, Time.current)
end
def restore
return true unless deleted?
update(deleted_at: nil)
end
def deleted?
deleted_at.present?
end
def destroy
soft_delete
end
def hard_destroy
super_method = method(:destroy).super_method
super_method ? super_method.call : delete
end
end
class DocumentsController < ApplicationController
before_action :set_document, only: :destroy
before_action :set_deleted_document, only: :restore
def destroy
@document.destroy
redirect_to documents_path, notice: "Document moved to trash."
end
def restore
if @document.restore
redirect_to @document, notice: "Document restored."
else
redirect_to trash_documents_path, alert: "Could not restore document."
end
end
private
def set_document
@document = current_user.documents.find(params[:id])
end
def set_deleted_document
# Must opt out of the default scope to find a soft-deleted row.
@document = current_user.documents.with_deleted.find(params[:id])
end
end
Soft-deleting means marking a row as gone with a deleted_at timestamp instead of running a destructive DELETE. This snippet shows the whole loop across three collaborating files: a migration that adds the column and adjusts uniqueness, a reusable SoftDeletable concern that hides deleted rows and adds restore behavior, and the controller that wires up destroy and a custom restore action.
In add_soft_delete_to_documents migration, a nullable deleted_at column is added with an index, since it is queried on every request. The migration also drops the plain unique index on slug and replaces it with a partial index (where: "deleted_at IS NULL"). This matters: without it a user could not reuse the slug of a soft-deleted record, and truly deleting-then-recreating would collide. Scoping uniqueness to live rows keeps the constraint useful while allowing soft-deleted duplicates to linger.
The SoftDeletable concern centralizes the pattern. Its default_scope filters to deleted_at: nil so ordinary queries, associations, and finders automatically skip deleted rows — the big convenience, and also the big pitfall, since a default_scope silently affects everything and can surprise callers. Named scopes with_deleted and only_deleted provide escape hatches. unscoped is used inside with_deleted to bypass the default scope cleanly. The instance methods soft_delete, restore, and deleted? toggle the timestamp; soft_delete uses update_column to skip validations and callbacks so removal cannot be blocked by a stale validation, while restore uses update so any re-activation logic still runs.
Overriding destroy makes the model soft-delete by default, so existing destroy call sites and dependent: :destroy associations keep working without changes. A separate hard_destroy remains for genuine purges (GDPR, admin cleanup).
In DocumentsController, destroy simply calls document.destroy and the record disappears from normal views. The restore action loads through with_deleted — necessary because the default scope would otherwise make the deleted record unfindable — then calls restore. This is the key edge case: any controller working with deleted rows must opt out of the default scope explicitly. The trade-off is more careful querying in exchange for recoverable data and audit-friendly history.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
Share this code
Here's the card — post it anywhere.