How to avoid N + 1 and keep your Ruby on Rails controller clean
Where to put the logic related to database queries optimization in a Ruby on Rails application. We want to answer this question by showing you the ‘Facade’ approach. #
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.
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:
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 .