Soft delete in Rails, and the four things default_scope breaks
You add a deleted_at column, a default_scope, and an hour later everything looks right. The
rows disappear from the index page, the data is still in the table, and you move on. The four
things below are what you find out later, and only one of them throws an error when it happens.
Everything here was measured on Rails 8.1.4 against a rails new --minimal app with two models,
a Post and its Comment.
class Post < ApplicationRecord
default_scope { where(deleted_at: nil) }
has_many :comments
validates :title, uniqueness: true
def discard! = update!(deleted_at: Time.current)
end
The happy path does work, and it's worth seeing before the rest. After post.discard!,
Post.count is 0, Post.unscoped.count is 1, and Post.find(post.id) raises
ActiveRecord::RecordNotFound. The scope compiles to exactly what you'd write by hand,
"posts"."deleted_at" IS NULL.
Your uniqueness validation ignores the scope
This is the one that surprises people, and it isn't a bug.
post.discard!
Post.new(title: "Hello").valid? # => false
The post is gone from every query you'll ever write, and the validation still says the title is taken. The reason is one line in Rails:
# activerecord-8.1.4/lib/active_record/validations/uniqueness.rb:113
def build_relation(klass, attribute, value)
relation = klass.unscoped
unscoped, deliberately. A uniqueness validation that honoured default_scope would let you
create duplicates that the scope happens to hide, which is a worse default than this one. But it
means your soft delete does the opposite of what people expect: the name stays reserved by a row
nobody can see.
If you want the title to become available again, the validation has to say so:
validates :title, uniqueness: { conditions: -> { where(deleted_at: nil) } }
The index underneath has the same argument, louder
A validation is a suggestion. The index is the thing that actually decides, and the shape you pick here is the whole design.
With the validation out of the way so the database answers for itself:
| Index | Reinsert the same title after a discard | Two live rows with that title |
|---|---|---|
UNIQUE (title) |
ActiveRecord::RecordNotUnique |
RecordNotUnique |
UNIQUE (title) WHERE deleted_at IS NULL |
allowed | RecordNotUnique |
The partial index is the one you want, and it's four extra words:
add_index :posts, :title, unique: true, where: "deleted_at IS NULL"
Now the constraint says what you actually mean, which is that live titles are unique and dead ones
aren't anybody's business. Postgres and SQLite both support this. MySQL doesn't, and on MySQL the
usual workaround is a generated column or storing 0 instead of NULL for live rows, which is
worth knowing before you pick the pattern rather than after.
belongs_to goes quiet
Here's the one with no error message at all.
Comment.create!(post: post, body: "hi")
post.discard!
Comment.count # => 1, the comment is still here and still live
Comment.first.post # => nil
The association reads through the same default_scope, finds nothing, and hands back nil. It
doesn't raise, it doesn't warn, and belongs_to's own presence validation only runs on save, so an
existing comment is perfectly happy.
What breaks is every view that already worked. comment.post.title was fine yesterday and is a
NoMethodError today, on a page nobody touched, triggered by an action taken somewhere else
entirely. If you've ever seen undefined method 'title' for nil appear in your exception tracker
with no deploy behind it, this is a good candidate.
And notice the first line of that snippet: the children weren't discarded. dependent: :destroy
fires on destroy, and a soft delete is an update. You either discard the children yourself or
you accept orphans, and the second one is a decision rather than an accident only if you make it
on purpose.
The scope assigns, as well as filters
A smaller one, but it explains a class of bug that looks impossible.
Post.new.deleted_at # => nil
Post.where(deleted_at: 2.days.ago).new.deleted_at # => preset from the scope
Any where with an equality condition seeds those attributes on records built through it. Most of
the time this is a convenience. Combined with a soft delete it means a record can be born deleted,
and the place it tends to happen is inside an association or a scope chain somebody wrote months
ago.
So should you use default_scope at all
If what you need is for one screen to hide archived rows, you don't. A named scope,
scope :live, -> { where(deleted_at: nil) }, costs you none of the above. You type Post.live in
the four places that want it, and the other thirty parts of your app keep behaving normally. The
validator still sees everything, but it was going to anyway, and now that's consistent rather than
surprising. The cost is that forgetting it shows archived rows, which is a visible bug you fix in
one line, in the one place it appeared.
default_scope is the right call when hiding the row is a rule rather than a preference, when
leaking one into a view would be a real incident, and when you're prepared to pay the four prices
above. Reach for it second, not first.
What this page doesn't cover
No gem was installed. Discard and Paranoia both automate parts of this, and they both sit on the same mechanics, so the four behaviours above are what you're buying a wrapper around rather than an alternative to.
Nothing here touches restoring a row, which sounds trivial and isn't the moment a unique index rejects the undelete. Nor cascading a discard down an association tree, nor what any of this does to counter caches, which keep counting rows the scope has stopped returning and go wrong in a way no query will show you.
Comments
No comments yet. Be the first.