Skip to content

Instantly share code, notes, and snippets.

@palkan
Last active Jan 21, 2022
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
Copy link

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
Copy link
Author

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
Copy link

rubendinho commented Mar 11, 2020

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

@rubendinho
Copy link

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?

@maslenkov
Copy link

maslenkov commented Aug 10, 2021

I've tried to use your config but got an error:

undefined method `then' for #<Array:0x00007fa7892f7a78>
/Users/my_user/projects/my_project/argon/app/graphql/loaders/association_loader.rb:51:in `preload_association'
/Users/my_user/projects/my_project/argon/app/graphql/loaders/association_loader.rb:34:in `perform'
/Users/my_user/.asdf/installs/ruby/2.5.8/lib/ruby/gems/2.5.0/gems/graphql-batch-0.4.3/lib/graphql/batch/loader.rb:62:in `resolve'

I've solved my issue with another approach, but I decided to write to you because it can be useful for future users.
Env:

  • ruby 2.5.8
  • rails 5.1.7
  • graphql 1.11.1
  • graphql-batch 0.4.3

@palkan
Copy link
Author

palkan commented Aug 10, 2021

@maslenkov

undefined method `then' for #Array:0x00007fa7892f7a78

This is #then from Ruby 2.6; try replacing it with #yield_self.

@maslenkov
Copy link

maslenkov commented Aug 13, 2021

This is #then from Ruby 2.6; try replacing it with #yield_self.

That's work! Thanks a lot!

@dleavitt
Copy link

dleavitt commented Jan 21, 2022

Don't think this works on Rails 7 - preloading changed a bit, sig of ActiveRecord::Associations::Preloader changed, ::ActiveRecord::Associations::Preloader::AlreadyLoaded doesn't exist any more, etc.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment