Reusing Rails Scopes

In this post, I would like to discuss about one of the least explored methods of ActiveRecord
i.e. merge. I came across this method after working with Rails for about 2 years.
It mainly focuses on merging of scopes in associations. Confused ?? Let’s head straight to an example.

Let’s say I’ve orders, line items and products as follows :

class Order < ApplicationRecord

  has_many :line_items
  has_many :products, through: :line_items

end
class LineItem < ApplicationRecord

  belongs_to :order
  belongs_to :product

end
class Product < ApplicationRecord
  scope :popular, -> { where("products.published = ? and products.bought_count > ?", true, 200) }
end

Now here I would like to find out all the orders with popular products. Normally we would approach the problem as follows:

  Order.joins(:products).where("products.published = ? and products.bought_count > ?", true, 200)

Or

class Order < ApplicationRecord

  has_many :line_items
  has_many :products, through: :line_items

  scope :with_popular_products, -> { joins(:products).where("products.published = ? and products.bought_count > ?", true, 200) }
end
Order.with_popular_products

The resulting query is

  SELECT "orders".*
  FROM "orders"
  INNER JOIN "line_items" ON "line_items"."order_id" = "orders"."id"
  INNER JOIN "products" ON "products"."id" = "line_items"."product_id"
  WHERE (products.published = 't' and products.bought_count > 200)

Here, the issues are

  1. Our code does not abide by the DRY principle.
  2. If the business logic for a popular product changes, we would have to take care we change it in every place being used.
  3. The popular product logic should have nothing to do with our Order model. It should stay encapsulated within the product.

How does merge come to the rescue ?

Let’s see for ourselves how merge can help us get rid of the above issues. We can rewrite the above scope as

scope :with_popular_products, -> { joins(:products).merge(Product.popular) }

results in

  SELECT "orders".*
  FROM "orders"
  INNER JOIN "line_items" ON "line_items"."order_id" = "orders"."id"
  INNER JOIN "products" ON "products"."id" = "line_items"."product_id"
  WHERE (products.published = 't' and products.bought_count > 200)

It fires the same query as above but code is much more cleaner and avoids replication.

Here we can see that the product logic remains within the product and we can reuse it whenever and wherever required.

There are some more ways of using merge

  1. Performing a join with multiple where conditions across tablesLet’s suppose we need to find out the delivered orders whose products are published.
    There can be two ways to do it

    Order.joins(:products).where(status: :delivered, products: { published: true})

    OR

    Order.where(status: :delivered).joins(:products).merge(Product.where(published: true))

    Both will result in the same query as follows

    SELECT "orders".*
    FROM "orders"
    INNER JOIN "line_items" ON "line_items"."order_id" = "orders"."id"
    INNER JOIN "products" ON "products"."id" = "line_items"."product_id"
    WHERE ("orders"."status" = 'delivered' and products.published = 't')

    But what if my Product model has a different table name say deals

    class Product < ApplicationRecord
      scope :popular, -> { where("products.published = ? and products.bought_count > ?", true, 200) }
    
      def self.table_name
        'deals'
      end
    end

    I’ll have to change my where query as per the table name and will always have to keep in mind such table name mappings

    Order.joins(:products).where(status: :delivered, deals: { published: true})

    But do you know what will happen with the query using merge ?
    Voila! No change needed!

  2. Merging two resultsImagine you’ve a website of various technical course videos and these videos can be accessed as per the user’s accessibility.
    For example, A guest user can view say just first video of each course, a user who has an account can view some free courses, a user with subscription can view the paid courses too.

    Suppose we’re using CanCan to manage the abilities of the user. Now to find the videos accessible by a user we can use

    Video.accessibe_by(current_ability)

    where current_ability tells me the access rights of the user (guest/free/subscription).

    Now if I want to find the videos accessible by the current user which are also published

    accessible_videos = Video.accessibe_by(current_ability)
    Video.where(published: true).merge(accessible_videos)

    It returns the intersection of all published videos with the ones accessible by current user.

Please drop in your suggestions/feedback in the comments below to help me improve.

Leave a Reply

Your email address will not be published. Required fields are marked *