Scopes in Rails

rails, scopes,

Swapnil Gourshete Swapnil Gourshete Follow Jul 11, 2019 · 5 mins read
Scopes in Rails

Recently I was calling create method on an active-record model object. But it failed in validation, because the provided foreign_key_id was not present in the associated table.

My first guess was it might be a dangling reference. But I was wrong. There was a record with this foreign_key_id in its table. So what went wrong? It was a problem with default scope defined on an associated model.

Let’s first understand how scopes work in Rails. When we define a scope on an ActiveRecord model

  class Vehicle < ActiveRecord::Base
    scope :red, -> { where(color: 'red') }
  end

a class method is added to the Model. It is simply like this

  class Vehicle < ActiveRecord::Base
    def self.red
      where(color:'red')
    end
  end

That means instead of querying Vehicle.where(color:'red'), we would be simply doing

  Vehicle.red

The object returned by scope is always an ActiveRecord::Relation not Array. And this is the greatest benefit, because you can call all the queries on this object just like any other Relation object.

  Vehicle.red
  Vehicle.red.count
  Vehicle.red.where(type: 'Hatchback')

You should always pass a callable object to the scopes defined with #scope. This ensures that the scope is re-evaluated each time it is called.

.

Default Scope

What if we need to query Vehicle model with some pre-fixed parameters. Suppose requirement is to show vehicles of drive type ‘Gear’ only. We must add the where(drive_type: 'Gear') clause to all the requests. Is there a better way with scopes?

Yes!!

Just by defining default scope we would be able to query the drive_type geared vehicles only. Just like

  class Vehicle < ActiveRecord::Base
    default_scope {where(drive_type: 'gear')}
  end

This will add where(drive_type: 'gear') to all the request we make to Vehicle.

This is just one way to use default scope. You can use default scopes n number of times in a Model. They all will be club together in the resulting query.

  class Vehicle < ActiveRecord::Base
    default_scope {where(drive_type: 'gear')}
    default_scope {where(color: 'red')}
  end

In this way we are able to filter each request on Model with default scope.

..

Unscoped

As much as we need default scope, we may also need to undo it at some places. It means calling a plain query on a Model without any pre-defined clause/s. It can be just like

  Vehicle.unscoped.count

It will result Vehicle.count by removing default clause where(drive_type: 'gear')

How associations work with Scopes

Scopes have effect on associated classes. The default scope will take effect when a parent class makes query to the associated class. Just like

  class Klass < ActiveRecord::Base
    has_many :vehicles
  end

  class Vehicle < ActiveRecord::Base
    belongs_to :klass
    default_scope {where(drive_type: 'gear')}
  end

When a query is made from klass object to vehicles, the default scope drive_type: 'gear' will be auto considered.

….

Extended Scopes

Scopes can be extended to have methods defined within themselves. Suppose

  class Vehicle < ActiveRecord::Base
    scope :geared, -> {where(drive_type: 'gear')} do
      def count
        count
      end
      
      def pluck_ids
        pluck(:id)
      end
  end

Calling Vehicle.geared.count would be equivalent to Vehicle.where(drive_type: 'gear').count and likewise for pluck_ids.

Why not class methods?

From all the discussion about scopes, you might think why not use class methods instead? And yes it is absolutely fine to choose class methods over scopes. The only problem I see for now is the difference between how we handle the chain-ability of relations.

For instance, consider

  #Scopes
  class Vehicle < ActiveRecord::Base
    scope :color, -> color { where(color: color) }
    scope :gear, -> { where(drive_type: 'gear') }
  end

  #Class method
  class Vehicle < ActiveRecord::Base
    def self.color(color)
      where(color: color)
    end
    
    def self.gear
      where(drive_type: 'gear')
    end
  end

When we call Vehicle.color('red').gear, it would absolutely work. But for Vehicle.color('').gear we won’t get any result, because the argument passed to color is blank. And it won’t be a desired output. If argument is nil or blank we would want to get all result instead. And will do this like

  #Scopes
  class Vehicle < ActiveRecord::Base
    scope :color, -> color { where(color: color) if color.present? }
  end

  #Class method
  class Vehicle < ActiveRecord::Base
    def self.color(color)
      if color.present?
        where(color: color)
      else
        all
      end
    end
  end

It is upto you what to choose. But I think we should consider what framework has to offer before re-inventing the wheel.

Edge case

Let’s assume data for more clear picture

state_id is present in every cities record. And State model has a default scope on column_1 as where(column_1: 1).

  class State < ActiveRecord::Base
    scope :one, -> { where(column_1: 1) }
    has_many :cities
  end

  class City < ActiveRecord::Base
    belongs_to :state
  end

To insert {name:'New York', state_id: 1} into cities, record with id 1 must exist in states table. But what if states id 1 has column_1 set to 2 or any value other than 1. Will the default scope neglect them? Absolutely Yes!!

The insertion query will fail with error message ActiveRecord::RecordInvalid: Validation failed: State must exist.

Then, what is the solution?

  class State < ActiveRecord::Base
    scope :one, -> { where(column_1: 1) }
    has_many :cities
  end

  class City < ActiveRecord::Base
    belongs_to :state, ->{where(column_1: [1, 2, 3])}
  end

Defining allowed column_1 values in the association itself will do the trick.

Cheers!!!


Swapnil Gourshete
Written by Swapnil Gourshete Follow
Hi I am Swapnil, a Software Engineer and computer science enthusiastic