ActiveRecord Eager Loading

Published 14-10-2018

This article was written as part of the team at Next Empire.

Seamless ActiveRecord Eager Loading with Query Objects and Decorators We were recently in the situation where we wanted to query all of a User's friends that attended the same Events. The query is somewhat complicated because of the two many-to-many relationships (user's friends and event's attendees). The basic models looked like this:

class User
  has_many :friendships
  has_many :friends, through: :friendships
  has_many :attendances
  has_many :events, through: :attendances
end

class Event
  has_many :attendances
  has_many :attendees, through: :attendances, source: :user
end

Fetching friends

For fetching the attending friends, a method was added to Event, as such:

class Event
  def attending_friends_for_user(user)
    attendees.friends_for(user)
  end
end

class User
  scope :friends_for, -> (user) { includes(:friendships).where(friendships: { friend_id: user.id }) }
end

Note that the attending_friends_for_user could've been implemented as a named scope as well, but using a method makes it easier to optimize later.

With this method, we can now easily show attending friends for a particular user.

- @events.each do |event|
  %ul
  - event.attending_friends_for_user(current_user).each do |friend|
    %li= friend.name

This works, but it poses a problem when the number of events gets large. A query is executed for every individual event, resulting in N+1 queries being executed for this list (one for fetching a list of events, and then one for every event). Such a design can result in really slow requests in production.

Eager loading

To solve the N+1 query problem, we took the approach by Thoughtbot. Many thanks to Thoughtbot for publishing their article. It was instrumental for designing our solution. We think we improved upon the Thoughtbot implementation in such a way that views do not need to be updated. This decreases the coupling of controllers/views which is generally good for portability of code. Like Thoughtbot, we make use of a Query Object and a Decorator.

The Query Object

The Query Object is defined as follows:

class EventsFeed
  include Enumerable
  delegate :each, :<<, to: :events

  def initialize(events:, user:)
    @events = events
    @user = user
  end

  def events
    @events
  end
end

class EventsController < ApplicationController
  def index
    @events = EventsFeed.new(events: Event.all, user: current_user)
  end
end

We extended the EventsFeed with Enumerable, so we seamlessly iterate over the feed and we don't need to adapt our view as defined earlier. Note that the instance variable of the feed is still called @events, such that the view can access it like a usual query result. The Decorator We create the decorator as a subclass of SimpleDecorator and apply this decorator to all events in the feed.

class EventWithFriends < SimpleDelegator
  def attending_friends_for_user(user)
    super(user)
  end
end

class EventsFeed
  def events
    @events.map { |event| EventWithFriends.new(event) }
  end
end

As-is, the decorator does nothing but delegate attending_friends_for_user back to the Event class, but this presents the possibility to implement eager loading here, abstracted away from the Event class. Implementing eager loading In the decorator we overwrite the attending_friends_for_user(user) method. Here we return an eager loaded result if available and otherwise fall-back to the Event class' implementation. This function is defined as

class EventWithFriends
  def initialize(object, attending_friends_by_user_id)
    super(object)
    @attending_friends_by_user_id = attending_friends_by_user_id
  end
  
  def attending_friends_for_user(user)
    @attending_friends_by_user_id[user.id] || super(user)
  end
end

class EventsFeed
  def initialize(events:, user:)
    @user = user
    @events = events
    @friend_cache = build_friend_cache
  end
  
  def events
    @events.map { |event| EventWithFriends.new(event, @user.id => @friend_cache[event.id]) }
  end
  
  private
  
  def build_friend_cache
    friend_hash = {}
    all_friends = friends
    @events.each do |event|
      friend_hash[event.id] = all_friends.select { |friend| friend.event_id == event.id }
    end
    friend_hash
  end
  
  def friends
    return [] if @events.empty? # Possible scenario in our app
    @user.friends.joins(:attendances).where(attendances: { event_id: @events.ids }).select(‘“users”.*, ‘“attendances”.”event_id”’)
  end
end

Conclusion

Fetching the events and the associated friends now requires only 2 queries and will run noticeably faster with large numbers of events. The implementation nicely abstracts the eager loading away from the Event object. Since we had other objects where we wanted the same ability to fetch friends, we ended up moving the functionality into a WithFriends concern which could then simply be included by EventWithFriends.