Rails Scope returns all instead of nil

17,925

This is not a bug or weirdness, after some research i've found its designed on purpose.

First of all,

  1. The scope returns an ActiveRecord::Relation

  2. If there are zero records its programmed to return all records which is again an ActiveRecord::Relation instead of nil

The idea behind this is to make scopes chainable (i.e) one of the key difference between scope and class methods

Example:

Lets use the following scenario: users will be able to filter posts by statuses, ordering by most recent updated ones. Simple enough, lets write scopes for that:

class Post < ActiveRecord::Base
  scope :by_status, -> status { where(status: status) }
  scope :recent, -> { order("posts.updated_at DESC") }
end

And we can call them freely like this:

Post.by_status('published').recent
# SELECT "posts".* FROM "posts" WHERE "posts"."status" = 'published' 
#   ORDER BY posts.updated_at DESC

Or with a user provided param:

Post.by_status(params[:status]).recent
# SELECT "posts".* FROM "posts" WHERE "posts"."status" = 'published' 
#   ORDER BY posts.updated_at DESC

So far, so good. Now lets move them to class methods, just for the sake of comparing:

class Post < ActiveRecord::Base
  def self.by_status(status)
    where(status: status)
  end

  def self.recent
    order("posts.updated_at DESC")
  end
end

Besides using a few extra lines, no big improvements. But now what happens if the :status parameter is nil or blank?

Post.by_status(nil).recent
# SELECT "posts".* FROM "posts" WHERE "posts"."status" IS NULL 
#   ORDER BY posts.updated_at DESC

Post.by_status('').recent
# SELECT "posts".* FROM "posts" WHERE "posts"."status" = '' 
#   ORDER BY posts.updated_at DESC

Oooops, I don’t think we wanted to allow these queries, did we? With scopes, we can easily fix that by adding a presence condition to our scope:

scope :by_status, -> status { where(status: status) if status.present? }

There we go:

Post.by_status(nil).recent
# SELECT "posts".* FROM "posts" ORDER BY posts.updated_at DESC

Post.by_status('').recent
# SELECT "posts".* FROM "posts" ORDER BY posts.updated_at DESC

Awesome. Now lets try to do the same with our beloved class method:

class Post < ActiveRecord::Base
  def self.by_status(status)
    where(status: status) if status.present?
  end
end

Running this:

Post.by_status('').recent
NoMethodError: undefined method `recent' for nil:NilClass

And :bomb:. The difference is that a scope will always return a relation, whereas our simple class method implementation will not. The class method should look like this instead:

def self.by_status(status)
  if status.present?
    where(status: status)
  else
    all
  end
end

Notice that I’m returning all for the nil/blank case, which in Rails 4 returns a relation (it previously returned the Array of items from the database). In Rails 3.2.x, you should use scoped there instead. And there we go:

Post.by_status('').recent
# SELECT "posts".* FROM "posts" ORDER BY posts.updated_at DESC

So the advice here is: never return nil from a class method that should work like a scope, otherwise you’re breaking the chainability condition implied by scopes, that always return a relation.

Long Story Short:

No matter what, scopes are intended to return ActiveRecord::Relation to make it chainable. If you are expecting first, last or find results you should use class methods

Source: http://blog.plataformatec.com.br/2013/02/active-record-scopes-vs-class-methods/

Share:
17,925
Ryan
Author by

Ryan

Updated on June 05, 2022

Comments

  • Ryan
    Ryan almost 2 years

    I'm running into a strange issue creating a scope and using the first finder. It seems as though using first as part of the query in a scope will make it return all results if no results are found. If any results are found, it will correctly return the first result.

    I have setup a very simple test to demonstrate this:

    class Activity::MediaGroup < ActiveRecord::Base
      scope :test_fail, -> { where('1 = 0').first }
      scope :test_pass, -> { where('1 = 1').first }
    end
    

    Note for this test, I have set where conditions to match records or not. In reality, I am querying based on real conditions, and getting the same strange behavior.

    Here are the results from the failing scope. As you can see, it makes the correct query, which has no results, so it then queries for all matching records and returns that instead:

    irb(main):001:0> Activity::MediaGroup.test_fail
      Activity::MediaGroup Load (0.0ms)  SELECT "activity_media_groups".* FROM "activity_media_groups" WHERE (1 = 0) ORDER BY "activity_media_groups"."id" ASC LIMIT 1
      Activity::MediaGroup Load (0.0ms)  SELECT "activity_media_groups".* FROM "activity_media_groups"
    => #<ActiveRecord::Relation [#<Activity::MediaGroup id: 1, created_at: "2014-01-06 01:00:06", updated_at: "2014-01-06 01:00:06", user_id: 1>, #<Activity::MediaGroup id: 2, created_at: "2014-01-06 01:11:06", updated_at: "2014-01-06 01:11:06", user_id: 1>, #<Activity::MediaGroup id: 3, created_at: "2014-01-06 01:26:41", updated_at: "2014-01-06 01:26:41", user_id: 1>, #<Activity::MediaGroup id: 4, created_at: "2014-01-06 01:28:58", updated_at: "2014-01-06 01:28:58", user_id: 1>]>
    

    The other scope operates as expected:

    irb(main):002:0> Activity::MediaGroup.test_pass
      Activity::MediaGroup Load (1.0ms)  SELECT "activity_media_groups".* FROM "activity_media_groups" WHERE (1 = 1) ORDER BY "activity_media_groups"."id" ASC LIMIT 1
    => #<Activity::MediaGroup id: 1, created_at: "2014-01-06 01:00:06", updated_at: "2014-01-06 01:00:06", user_id: 1>
    

    If I perform this same logic outside of a scope, I get the expected results:

    irb(main):003:0> Activity::MediaGroup.where('1=0').first
      Activity::MediaGroup Load (0.0ms)  SELECT "activity_media_groups".* FROM "activity_media_groups" WHERE (1=0) ORDER BY "activity_media_groups"."id" ASC LIMIT 1
    => nil
    

    Am I missing something here? This seems like a bug in Rails/ActiveRecord/Scopes to me unless there is some unknown behavior expectations I am unaware of.

  • Ryan
    Ryan over 10 years
    Using limit, even with a limit of 1, returns an Active Record Relation though, whereas first returns an instance of the model. So, the functionality is different, and in my case that matters. Is there any other way to get one model, if it exists, from the scope, and not a relation?
  • Siva
    Siva over 10 years
    @Ryan is there any reason for you to not use class methods ?
  • Ryan
    Ryan over 10 years
    Thanks for the info, I see what you're saying... and I see the reasoning. Although I still don't think I fully agree that a scope should return something that is explicitly not true. Can an active record relation not contain zero records? I guess you just have to be very conscious of not using scopes if using functions like first, last, or find.
  • Yo Ludke
    Yo Ludke almost 10 years
    Perfect answer I had the opposite question of the OP but it still helped me!
  • Patelify
    Patelify over 9 years
    Good answer. When working with custom scopes, I found returning nil accomplished the exact goal that I thought returning self from within a custom scope would accomplish. Returning self caused the previous scope chaining to be ignored. Returning nil allowed for the previous chaining to be used. Kudos!
  • cavpollo
    cavpollo about 8 years
    Thanks! Super Long Story Short: scopes + where doesn't return nil. Ever.
  • Tilo
    Tilo about 7 years
    but you can return scoped instead of all
  • Dorian
    Dorian over 2 years
    limit doesn't return a model but a collection/active record relation