How to avoid N + 1 and keep your Ruby on Rails controller clean

Image description

Outline the problem

There is an application that stores a database of films and reviews. Every review has its own rate value.

class Film < ApplicationRecord
  has_many :reviews
end
class Review < ApplicationRecord
  belongs_to :film
end

We can calculate the average rate of each film,

class Film < ApplicationRecord
  has_many :reviews

  def avg_rating
    reviews.average(:rate)
  end
end

and use this method in controller:

class FilmsController < ApplicationController
  def index
    @films = serialized_films
    render json: @films
  end

  def serialized_films
    Film.all.map do |film|
      {
        title: film.title,
        genre: film.genre,
        rate: film.avg_rating
      }
    end
  end
end

If we need to show some films with their average ratings, this avg_rating method will issue an avg query every time it’s called, which will lead to a lot of individual queries.

Image description

How to reduce the query to two

Let’s update the controller to get all the data in just two queries. The first request receives data for all films. The second query uses an aggregation function for receiving an average rating for each movie and stores it in a cached variable.


class FilmsController < ApplicationController
  def index
    @films = serialized_films
    render json: @films
  end

  private

  def films_scope
    Film.all
  end

  def serialized_films
    films_scope.map do |film|
      { title: film.title, genre: film.genre, rate: films_rates[film.id] }
    end
  end

  def films_rates
    @films_rates ||= films_scope.includes(:reviews).group(:film_id).average(:rate)
  end
end

And now we receive data without N+1 problem:

Image description

Where to store code for receiving data

But should we have this code in the controller? Let’s try to use the Facade pattern, and move the code to a separate FilmCarrier class.

class FilmCarrier
  delegate_missing_to :@film

  def initialize(film, cached_stats: nil)
    @film = film
    @cached_ratings_stats = cached_stats || calculate_stats(film)
  end

  def avg_rating
    @cached_ratings_stats[:avg_ratings][@film.id]
  end

  def self.wrap(film_scope)
    cache = { avg_ratings: calculate_totals(film_scope) }

    film_scope.map { |film| FilmCarrier.new(film, cached_stats: cache) }
  end

  def self.calculate_totals(films)
    films.includes(:reviews).group(:film_id).average(:rate)
  end

  def calculate_stats(film)
    { avg_ratings: { film.id => film.avg_rating } }
  end
end

The class method *wrap *is an implementation of the Facade pattern. It returns an array of the *FilmCarrier *instances for each film from the scope with the instance method avg_rating.

And now we have clean controller:


class FilmsController < ApplicationController
  def index
    @films = serialized_films
    render json: @films
  end

  private

  def films_scope
    Film.all
  end

  def serialized_films
    FilmCarrier.wrap(films_scope).map do |film|
      { title: film.title, genre: film.genre, rate: film.avg_rating }
    end
  end
end

The pros of this way

  • Ability to combine data from the different tables

  • Group the related logic

  • The code is easier to test

Paul Keen is an Open Source Contributor and a Chief Technology Officer at JetThoughts. Follow him on LinkedIn or GitHub.

If you enjoyed this story, we recommend reading our latest tech stories and trending tech stories.