nicoolas25
6/28/2015 - 12:53 PM

Custom relation with caching and preloading (ActiveRecord)

Custom relation with caching and preloading (ActiveRecord)

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