Why is Ruby on Rails' default_scope bad?
A pretty popular opinion in the Ruby on Rails community is that default_scopes should be avoided at all costs. Even though this is a widely expressed opinion, often at the time it’s hard for even Rails experts to express exactly how default_scopes are bad.
The reason is two-fold:
- When you request
Model.all
, it is right to assume you will be retrieving all records default_scope
does not always cooperate with other clauses
Using default_scope
s
Starting with a very basic Post
model that contains a deleted_at
column to
support soft delete, like so:
class CreatePosts < ActiveRecord::Migration[6.0]
def change
create_table :posts do |t|
t.string :title
t.text :body
t.datetime :deleted_at
t.timestamps
end
end
end
The application is supposed to only display posts that have not been deleted,
so a default_scope
seems like the perfect solution!
class Post < ApplicationRecord
default_scope -> { where(deleted_at: nil) }
end
This default_scope
successfully hides all posts that are deleted, which is great!
Additionally, the posts should be ordered by the date they were created, which
can also be added to the default_scope
.
class Post < ApplicationRecord
default_scope -> { where(deleted_at: nil).order(:created_at) }
end
Problem 1
Now that the application hide the proper posts by default, a feature request comes in for administrators to show deleted posts.
This is super easy with a scope, like so:
class Post < ApplicationRecord
default_scope -> { where(deleted_at: nil) }
scope :deleted, -> { where.not(deleted_at: nil) }
end
The issue occurs when this scope is used by calling Post.deleted
, no
results are returned, even when there are indeed deleted records.
This is caused by conflicting where
clauses, where the default_scope
requests for all records where posts.deleted_at IS NULL
and the scope
requests all records where posts.deleted_at IS NOT NULL
, which will always
be exactly 0 records.
Adding unscoped
to the deleted
scope kind of works:
class Post < ApplicationRecord
default_scope -> { where(deleted_at: nil) }
scope :deleted, -> { unscoped.where.not(deleted_at: nil) }
end
However, this now makes it impossible to build a where
clause like so:
Post.where(title: 'My Title').deleted
This is caused by deleted
effectively unscope
ing all of the where clauses
prior, which removes the where
clause, displaying all deleted records.
Problem 2
If we want to order the posts by updated_at
, we would
typically use the following code:
Post.order(:updated_at)
With the default_scope applied, this results in being ordered by created_at
first, then updated_at
, deeming this order useless…
Instead, it must be remembered to reorder
, which is not intuitive when debugging:
Post.reorder(:updated_at)
Problem 3
Finally, Post.all
no longer returns all records, which is misleading.
Solution
In Rails, default_scopes are always painful to work with and result in unexpected bugs, so it’s best to not use Rails’ default_scopes.
Instead, communicative scopes can be used:
class Post < ApplicationRecord
scope :active, -> { where(deleted_at: nil) }
scope :deleted, -> { where.not(deleted_at: nil) }
end
As far as the order clause, I typically just type that one out. It’s short,
sweet, and communicates the order within the line of code you’re using. Often I
combine these scopes to make a scope like for_index
in which specifies what
should be shown, and in what order, like so:
class Post < ApplicationRecord
scope :active, -> { where(deleted_at: nil) }
scope :deleted, -> { where.not(deleted_at: nil) }
scope :for_index, -> { active.order(:created_at) }
end
Even though it seems like more work to include these scopes almost every time you access the records, it ends up being less work as the app ages. In Rails, default_scopes should be avoided at all costs. You’ll regret using default_scopes, maybe not today, but some time in the future.
Related Articles
- Solved: Warning: Using the last argument as keyword parameters is deprecated
- Rails: How to Use Greater Than/Less Than in Active Record where Statements
- How to Open a New Tab Vim
- Solved: ActionController::ParameterMissing (param is missing or the value is empty)
- Vim: How to Open Current Opened File in New Tab