Rails Project Roundup - Scope Methods, Metaprogramming & Refactoring

Posted by Rachel Hawa on June 5, 2019

WINELOG

Building scope methods was one of the more challenging (and more rewarding) aspects of building my rails project.

The first one was straightforward - in the form to create a new wine, users could mark a wine as ‘favorite’. The goal was to query the database using Active Record query interface and return an array of favorite wines. The where method was the best fit, since I wanted to return multiple objects.

scope :is_favorite, -> {where(favorite: true)}

I also wanted to be able to return wines that were highly rated - even if the user hadn’t flagged that wine as a favorite. where worked perfectly here as well.

scope :highly_rated, -> {where("rating > ?", 7)}

The next set of methods were more complex - I wanted to sort wines by type and average rating, to give insight on what types of wine a user preferred, on average.

Building an average rating method is straightforward:

  def self.average_rating
    average(:rating)
  end

However, querying by wine type was not. Wine types (red, white, rose) are finite - so I opted not to create a model for it.

Still, I wanted to be able to sort wines by type to provide insights to the user - so I created a set of scope methods so I could query based on wine type:

  scope :red, -> {where(wine_type: "Red")}
  scope :white, -> {where(wine_type: "White")}	  
  scope :rose, -> {where(wine_type: "Rose")}	
  scope :sweet, -> {where(wine_type: "Sweet")}	  
  scope :sparkling, -> {where(wine_type: "Sparkling")}	 
  scope :other, -> {where(wine_type: "other")}	  

Now, I could call .red and return all red wines!

The first version of the top_rated class method was:

  def self.top_rated
    averages = {"Red" => Wine.red.average_rating.to_f,
                "White" => Wine.white.average_rating.to_f,
                "Rose"=> Wine.rose.average_rating.to_f,
                "Sparkling"=> Wine.sparkling.average_rating.to_f,
                "sweet"=> Wine.sweet.average_rating.to_f}
    top_type = averages.sort_by { |wine_type, avg| avg }.last[0]
  end

It worked - but it was long, and, because I was writing more than one scope method based on wine type, repetitious.

I made the code more DRY in two steps. First, I eliminated the hash in favor of iterating through the values in the :wine_type of the wines table using pluck.

def self.top_rated
    averages = {}
    types = Wine.pluck(:wine_type).uniq
    types.each do |type|
      averages[type] = Wine.where(wine_type: type).average_rating.to_f
    end
    top_type = averages.sort_by { |wine_type, avg| avg }.last[0]
end

This is cleaner, but still imperfect. Why query the database for an attribute that isn’t even an object, and I know the possible options for?

Creating a class contstant eliminated a line of code, and a query to the database.

WINE_TYPES = ["Red", "White", "Rose", "Sparkling", "Sweet", "Other"]

The final method is below:

  def self.top_rated
    averages = {}
    WINE_TYPES.each do |type|
      averages[type] = Wine.where(wine_type: type).average_rating.to_f
    end
    top_type = averages.sort_by { |wine_type, avg| avg }.last[0]
  end

I was also able to declutter the wines form with the class constant:

<%= f.label :wine_type, "Type:" %>
<%= f.select :wine_type, options_for_select(Wine::WINE_TYPES, selected: @wine.wine_type) %>

Instead of:

<%= f.label :wine_type, "Type:" %>
<%= f.select :wine_type, options_for_select(["Red", "White", "Rose", "Sparking", "Sweet", "Other"], selected: @wine.wine_type) %>

The scope methods for being able to filter by wine type are functional but inelegant and not scalable. What if I wanted to create more options? Six lines of code could grow quickly and become un-DRY and unwieldy. I was able to refactor once again, using the singleton class method. The singleton class is a form of metaprogramming - allowing for greater flexibility. It’s called inside the class - but not as a method, as it is defining a method itself. The implementation used for my app is below.

  self.singleton_class.class_eval do
    WINE_TYPES.each do |wine_type|
      define_method(wine_type.downcase.to_sym) do
        where(wine_type: wine_type)
      end
    end
  end

Refactoring was a multistep process that made my code easier to understand, less complex, and made it easier for me to add on additional methods.