articles = Article.where(author: author).load
# Load the related article using the `RelatedArticles#on_instance` method
articles.first.related_articles # Triggers a SQL query
# Preload all the related articles of the Article array using the
# `RelatedArticles#on_collection` method then use the `RelatedArticles#relaed?`
# method to dispatch the related_articles into the `articles` array.
Article.loadable_relations(:related_articles).load(articles)
# Everything is already loaded
articles.last.related_articles # Does not trigger any SQL query
class Keyword < ActiveRecord::Base
has_and_belongs_to_many :articles
end
class Article < ActiveRecord::Base
has_and_belongs_to_many :keywords
include ::Loadable::Model
loadable :related_articles, with: Loader::RelatedArticles
end
module Loader
class RelatedArticles
def on_instance(article)
# Here I'm using Ruby to get the record I want. This isn't very
# efficient but it explains well the articles I'm interested in.
article.keywords.map(&:articles).uniq - [article]
end
def on_collection(articles)
article_ids = articles.map(&:id).compact.uniq
return [] if article_ids.empty?
Article.find_by_sql <<-SQL
with original_articles_keywords as (
select articles.id as article_id, articles_keywords.keyword_id
from articles
join articles_keywords
on articles.id in (#{article_ids.join(",")})
)
select articles.*, array_agg(original_articles_keywords.article_id) as related_article_ids
from articles
join articles_keywords
on articles.id = articles_keywords.article_id
join original_articles_keywords
on articles_keywords.keyword_id = original_articles_keywords.keyword_id
group by articles.id;
SQL
end
def related?(article, related)
related.id != article.id &&
related.attributes["related_article_ids"].include?(article.id)
end
end
end
module Loadable
class Relation
extend Forwardable
class << self
def build(model_class, name, options)
Class.new(self) do
self.model = model_class
self.name = name
self.loader = options.fetch(:with).new
end
end
attr_accessor :model, :name, :loader
end
attr_accessor :instance
def load(collection=nil)
if instance.present?
load_instance
elsif collection.present?
load_collection(collection)
end
end
def loaded?
!!@elements
end
def loaded!(elements)
@elements = elements
end
private
def load_instance
return @elements if loaded?
loader.on_instance(instance).tap { |elements| loaded!(elements) }
end
def load_collection(collection)
loader.on_collection(collection).tap do |elements|
collection.each do |instance|
matches = elements.select { |element| loader.related?(instance, element) }
instance.__send__("#{name}_relation").loaded!(matches)
end
end
end
def_delegators "self.class", :model, :name, :loader
end
end
require "active_support/concern"
require "loadable/relation"
module Loadable
module Model extend ActiveSupport::Concern
module ClassMethods
def loadable(name, options)
loadable_relations[name] = build_relation(name, options)
define_reader(name)
end
def loadable_relations(name=nil)
@loadable_relations ||= {}
if name
@loadable_relations.fetch(name).new
else
@loadable_relations
end
end
private
def build_relation(name, options)
Relation.build(self, name, options)
end
def define_reader(name)
self.class_eval <<-CODE, __FILE__, __LINE__ + 1
def #{name}_relation
loadable_relations(:#{name})
end
def #{name}
#{name}_relation.load
end
CODE
end
end
def loadable_relations(name)
@loadable_loaded_relations ||= {}
@loadable_loaded_relations[name] ||=
self.class.loadable_relations(name).tap do |relation|
relation.instance = self
end
end
end
end