Scopes in Rails

Scopes is one of the features in Rails that I only learned about through reading The Rails 3 Way. It doesn’t seem to be a feature that’s used very often, but I think it can make your code a lot neater if it’s used correctly.

I’m going to illustrate how scopes can be used with a simple example.

A Product Application

I’ve created a very simple application to manage products. All the basic functionality is there – view/create/edit/delete.

List of Products

The controller code for viewing the list of products is very simple.

def index
  @products = Product.all
end

You’ll notice (in the screenshot) that each product has a status – ‘Available’ or ‘Coming Soon’. Let’s imagine that the user wants to be able to view all Available products. How might we accomplish that?

The easiest way to do this would be to add a separate route and action which simply filters the products to only those with the correct status. First, let’s add the route.

get 'products/available' => 'products#available', :as => 'available_products'

Now we only need to add the controller action.

def available
  @products = Product.where(:status => 'Available')
  render 'index'
end

This certainly works, but there’s a cleaner way of doing this.

Using a named scope

Instead of using the where method in the controller, we can move the behavior to the model. We could create a class method to expose this filter, but Rails has a much nicer way – named scopes. Let’s see what that looks like.

class Product < ActiveRecord::Base
  scope :available, where(:status => 'Available')
end

Now our controller code is much cleaner and easier to read.

def available
  @products = Product.available
  render 'index'
end

And everything still works exactly as before. Hurrah!

Using default scope

As I mentioned, we also have the functionality to delete a product. However, we might not want to delete the record out of the database – we simply want to mark it as deleted. (There are a number of reasons why we might choose to do this – for example, we might have transactions linked to a product)

Here’s the code for deleting a product.

def destroy
  @product = Product.find(params[:id])
  @product.update_attribute(:deleted, true)
  redirect_to products_path
end

Let’s take another look at our list of products.

List containing Deleted Products

The deleted products are showing up in our regular list! While this makes sense (since we’re simply flagging them as deleted – not actually deleting them), this is definitely not what we want. So how do we fix that? One way would be to update all our queries to include :deleted => false. As you probably guessed, Rails has a neater way of doing this – using default scope.

class Product < ActiveRecord::Base
  default_scope where(:deleted => false)
  scope :available, where(:status => 'Available')
end

As the name implies, default_scope changes the default list of products being returned. Our list of products no longer shows deleted products. For example, Product.all will exclude deleted products. Pretty neat.

What if we want to bypass the default scope and actually get all products? For example, I might want to have an admin function that lists all unavailable products – meaning products which have their status set to something other than Available. We can do that by using the unscoped method.

def unavailable
  @products = Product.unscoped.where('status <> ?', 'Available')
  render 'index'
end

Pretty cool.

Conclusion

This is one of those pieces of functionality that you probably won’t miss until you know it’s around. It can definitely clean up your code and make it a bit more readable. I would probably be wary of using default_scope, but in certain scenarios it probably makes sense.

If you would like to play around with this code you can find it on Github. Happy coding.

Tags: Rails