Last Updated: January 28, 2019
·
5.859K
· carlesso

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]]

1 Response
Add your response

Thank you! I was banging my head against the wall over this.

over 1 year ago ·