ActiveRecord unscoped trap
ActiveRecord allows you to define scopes (and default scopes), as you may know. But sometime you may need to access data without the default scope.
Let's see an example:
class User < ActiveRecord::Base
has_many :posts
end
class Post < ActiveRecord::Base
default_scope -> { where(draft: false) }
belongs_to :user
end
A very simple example, and that default scope will allow you to do not care about draft posts.
But you will soon find how this can easily fool you.
Let's say you need to access all drafts by a User, the obvious approach will be:
User.first.posts.where(draft: true)
And you will find an empty result set. That is because the default scope gets applied to, the resulting query is:
SELECT "posts".* FROM "posts" WHERE "posts"."draft" = 'f' AND "posts"."user_id" = $1 AND "posts"."draft" = 't' [["user_id", 1]]
As you can see you are looking for posts that are both draft and not draft, that leads to an empty set (unless you're working in a quantum system).
Well, then let me remove the scope:
User.first.posts.unscoped.where(draft: true)
And in your tests you may think that you've solved your problem, because trying this in your development system seems to present the right solution.
But let's look at the resulting query:
SELECT "posts".* FROM "posts" WHERE "posts"."draft" = 't'
Wait, where is out "posts"."user_id"
condition? The unscoped
method is way more powerful than you thought, right?
It is removing everything, even the user relation that we have in the same line!
This is the intended behavior but can lead to very hard-to-spot bugs.
We have a couple of solution here.
First one:
Post.unscoped{ User.first.posts.where(draft: true) }
Passing a block inside .unscoped
will remove the default scope, but all the conditions inside the block will be preserved.
Second (ugly) solution:
Use rewhere
User.first.posts.rewhere(draft: true)
Even if rewhere
is a powerful tool, at a first read you may be confused to see it without an explicit where
before.
Third (uglier) one:
Post.unscoped.where(user_id: User.first.id, draft: true)
We are just ignoring the belongs_to
here, looks more like an hack than a solution.
Fourth (best) solution:
Avoid using default_scope
at all
default_scope
brings more problem then benefits in the long run:
It changes (silently) the behavior of the most used instructions, like
Post.first
,Post.all
without any hint.
See also default_scope
is evil.
At the end of the story, having your Post
model this way:
class Post < ActiveRecord::Base
belongs_to :user
scope :published, -> { where(draft: false) }
scope :draft, -> { where(draft: false) }
end
And call posts
this way:
User.first.posts.draft
is way more clear and meaningful.
Footnote: be aware that also scope
are influenced by default_scope
. For example if you were hoping this solution works:
class Post < ActiveRecord::Base
default_scope -> { where(draft: false) }
belongs_to :user
scope :published, -> { where(draft: false) }
scope :draft, -> { where(draft: true) }
end
I have bad news for you, the resulting
User.first.posts.draft
will lead to a our quantum query:
SELECT "posts".* FROM "posts" WHERE "posts"."draft" = 'f' AND "posts"."user_id" = $1 AND "posts"."draft" = 't' [["user_id", 1]]
Written by Enrico Carlesso
Related protips
1 Response
Thank you! I was banging my head against the wall over this.