Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
Enhanced AssociationLoader
# frozen_string_literal: true
module Common
module GraphQL
# See https://github.com/Shopify/graphql-batch/blob/master/examples/association_loader.rb
#
# Additionally added support for scopes.
class AssociationLoader < ::GraphQL::Batch::Loader
def self.validate(model, association_name)
new(model, association_name)
nil
end
def initialize(model, association_schema, scope_model: nil, scopes: nil)
@model = model
@association_schema = association_schema
@association_name = extract_association_id(association_schema)
if scope_model && scopes
@preload_scope = scopes.reduce(scope_model) { |acc, scope| acc.public_send(scope) }
end
validate
end
def load(record)
raise TypeError, "#{@model} loader can't load association for #{record.class}" unless record.is_a?(@model)
return Promise.resolve(read_association(record)) if association_loaded?(record)
super
end
# We want to load the associations on all records, even if they have the same id
def cache_key(record)
record.object_id
end
def perform(records)
# Pass unique records to preloader to avoid duplicates
preload_association(records.uniq)
records.each { |record| fulfill(record, read_association(record)) }
end
private
def validate
unless @model.reflect_on_association(@association_name)
raise ArgumentError, "No association #{@association_name} on #{@model}"
end
end
def preload_association(records)
::ActiveRecord::Associations::Preloader.new.preload(
records,
@association_schema,
@preload_scope
).then(&:first).then do |preloader|
next unless @preload_scope && preloader
# The result of previous preload is memoized, ActiveRecord won't load this association again.
if preloader.is_a?(::ActiveRecord::Associations::Preloader::AlreadyLoaded)
owner = preloader.send(:owners).first
# We can use batch_load with the _one_ set of scopes many times during the request
raise ArgumentError,
"Preloading association twice with scopes is not possible. " \
"To resolve this problem add a scoped association (e.g., `has_many :records, -> { scope_name }, ...`) to the model"
end
# this commit changes the way preloader works with scopes
# https://github.com/rails/rails/commit/2847653869ffc1ff5139c46e520c72e26618c199#diff-3bba5f66eb1ed62bd5700872fcd6c632
preloader.send(:owners).each do |owner|
preloader.send(:associate_records_to_owner, owner, preloader.records_by_owner[owner] || [])
end
end
end
def read_association(record)
record.public_send(@association_name)
end
def association_loaded?(record)
record.association(@association_name).loaded?
end
def extract_association_id(id_or_hash)
return id_or_hash unless id_or_hash.is_a?(Hash)
if id_or_hash.keys.size != 1
raise ArgumentError, "You can only preload exactly one association! You passed: #{id_or_hash}"
end
id_or_hash.keys.first
end
end
end
end
@rubendinho

This comment has been minimized.

Copy link

@rubendinho rubendinho commented Mar 9, 2020

Thanks for putting this together! Would you be able to share some examples for how to pass in scopes?

@palkan

This comment has been minimized.

Copy link
Owner Author

@palkan palkan commented Mar 9, 2020

Scopes could be specified like this:

class City < ApplicationRecord
  has_many :homes
end

class Home < ApplicationRecord
  scope :keep, ->{ where(deleted_at: nil) }
  scope :ordered, ->{ order(name: :asc) }
end

module Types
  class City < Base
    field :homes, [Home]

    def homes
      AssociationLoader.for(
        City, :homes, scope_model: Home, scopes: %i[keep ordered]
      ).load(object)
    end
  end
end
@rubendinho

This comment has been minimized.

Copy link

@rubendinho rubendinho commented Mar 11, 2020

Great - thank you. I think scopes needs to be an array of symbols though: %i[keep ordered].

@rubendinho

This comment has been minimized.

Copy link

@rubendinho rubendinho commented Mar 11, 2020

@palkan I'm getting a "Preloading association twice with scopes is not possible. " error when using a scope but having 0 associated records.

class Types::ItemType < Types::BaseObject
  field :accounts, [Types::AccountType], 'Accounts', null: false

  def accounts
    AssociationLoader.for(
      Item, :accounts, scope_model: Account, scopes: %i[by_kind]
    ).load(object)
  end
end

In scenarios where I have accounts, everything is working fine, but in tests where there are no accounts are generated, I'm receiving the error.

I believe what's happening is that when there are no records, and @preload_scope is set to [] in line 20, that in turn this makes preloader.is_a?(::ActiveRecord::Associations::Preloader::AlreadyLoaded) evaluate to true in line 60- thus triggering a false positive.

EDIT: The solution of using present? in like 57, like so: next unless @preload_scope.present? solved the problem but stopped scopes from being properly applying.

Any ideas on how to resolve this?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.