Search code examples
mysqlruby-on-railsrubybelongs-to

Is there a way to preload an arbitrary number of parent associations in Rails?


TL;DR: I have a model that belongs_to :group, where group is another instance of the same model. That "parent" group can also have a parent, and so on up the chain. Is there a way to includes this structure as far up as it goes?

I have a Location model, which looks like this (abridged version):

create_table "locations", force: :cascade do |t|
  t.string "name"
  t.decimal "lat", precision: 20, scale: 15
  t.decimal "long", precision: 20, scale: 15
  t.bigint "group_id"
  t.string "type"
  t.datetime "created_at", null: false
  t.datetime "updated_at", null: false
  t.index ["group_id"], name: "index_locations_on_group_id"
end
class Location < ApplicationRecord
  belongs_to :group, class_name: 'Location', required: false
  has_many :locations, foreign_key: 'group_id', dependent: :destroy
end

In other words, it can optionally belong to a "parent" instance of itself, referenced as group.

That parent instance can also belong to a parent instance another level up, as can its parent, etc etc. Elephants, all the way down.

What I'd like to do is string the names of a Location and all its parent instances together, so I end up with something like "Top-level group > Mid-level group > Lowest group > Location". This is fine, and I've implemented that in the model already:

def parent_chain
  Enumerator.new do |enum|
    parent_group = group
    while parent_group != nil
      enum.yield parent_group
      parent_group = parent_group.group
    end
  end
end

def name_chain
  (parent_chain.map(&:name).reverse + [name]).join(" \u00BB ")
end

The only problem with this, however, is that it will query individually for each parent instance as it gets there (the N+1 problem). Once I'm including several Locations in a single page, this is a lot of queries slowing the load down. I'd like to preload (via includes) this structure as I would for a normal belongs_to association, but I don't know if there's a way to include an arbitrary number of parents like this.

Is there? How do I do it?


Solution

  • Using includes? No. Recursive preloading could be achieved this way though:

    Solution #1: True recursion

    class Location
      belongs_to :group
    
      # Impure method that preloads the :group association on an array of group instances.
      def self.preload_group(records)
        preloader = ActiveRecord::Associations::Preloader.new
        preloader.preload(records, :group)
      end
    
      # Impure method that recursively preloads the :group association 
      # until there are no more parents.
      # Will trigger an infinite loop if the hierarchy has cycles.
      def self.deep_preload_group(records)
        return if records.empty?
        preload_group(records)
        deep_preload_group(records.select(&:group).map(&:group))
      end
    end
    
    locations = Location.all
    Location.deep_preload_group(locations)
    

    The number of queries will be the depth of the group hierarchy.

    Solution #2: Accepting a hierarchy depth limit

    class Location
      # depth needs to be greather than 1
      def self.deep_preload_group(records, depth=10)
        to_preload = :group
        (depth - 1).times { to_preload = {group: to_preload} }
        preloader = ActiveRecord::Associations::Preloader.new
        preloader.preload(records, to_preload)
      end
    end
    

    The number of queries will be the minimum of depth and the actual depth of the hierarchy