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.allwithout 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.