Skip to content

Instantly share code, notes, and snippets.

@martinliptak
Last active March 6, 2020 15:36
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save martinliptak/70d9ceae47535951a0de7128c4888329 to your computer and use it in GitHub Desktop.
Save martinliptak/70d9ceae47535951a0de7128c4888329 to your computer and use it in GitHub Desktop.
module Loaders
class AssociationLoader < GraphQL::Batch::Loader
def initialize(
user,
model,
association,
apply: [],
aggregate: nil,
aggregate_default_value: 0
)
@user = user
@model = model
@association = association
@apply = apply
@aggregate = aggregate
@aggregate_default_value = aggregate_default_value
end
def perform(keys)
# Loading article -> tags, target class will be Tag.
unless @model.reflect_on_association(@association)
raise(
ArgumentError,
"Association #{@association} doesn't exist on #{@model.name}.",
)
end
target_class = @model.reflect_on_association(@association).klass
# Why do we need inverse associations?
#
# Imagine loading article -> tags. We could do Article.joins(:tags), but this
# 1) needlessly loads articles, which have already been
# loaded by the parent GraphQL query
# 2) doesn't apply LIMIT to tags
#
# Using inverse association, we can do Tag.joins(:article) avoiding both issues.
#
unless @model.reflect_on_association(@association).inverse_of
raise(
ArgumentError,
"Association #{@association} on #{@model.name} doesn't have an inverse association.",
)
end
inverse_association = @model.reflect_on_association(@association).inverse_of.name
# Make sure all loaded records are authorized
# (either with simple scopes on models or using a library like Pundit).
scope = target_class.authorized_scope_for(@user)
# Now doing Tag.joins(:articles).where(articles: { id: @model.id })
scope = scope.joins(inverse_association)
scope = scope.where(@model.arel_table[:id].in(keys))
# Additional named scopes or where conditions.
@apply.each do |method_and_params|
scope = scope.send(*method_and_params)
end
if @aggregate
# Group by Article.id
scope = scope.group(@model.arel_table[:id])
# For example, `count` aggregates tag count per article.
scope = scope.send(*@aggregate)
fulfill_aggregation(keys, scope)
else
# Select Article.id as __loader_key
scope = scope.select(
@model.arel_table[:id].as("__loader_key"),
target_class.arel_table[Arel.star]
)
# Default limit for security
scope = scope.limit(GalaxycodrSchema.default_max_page_size)
if multiple_results_per_key?
fulfill_multiple_results(keys, scope)
else
fulfill_single_result(keys, scope)
end
end
end
private
def multiple_results_per_key?
@association.to_s == @association.to_s.pluralize.to_s
end
def fulfill_aggregation(keys, scope)
# Fulfill results
scope.each do |key, result|
fulfill(key, result)
end
# Default value is 0 or other value the user provides.
keys.each do |key|
next if fulfilled?(key)
fulfill(key, @aggregate_default_value)
end
end
def fulfill_multiple_results(keys, scope)
# Group by __loader_key and fulfill keys
scope
.group_by { |record| record[:__loader_key] }
.each { |key, records| fulfill(key, records) }
# Default value is an empty array
keys.each { |key| fulfill(key, []) unless fulfilled?(key) }
end
def fulfill_single_result(keys, scope)
scope
.each { |record| fulfill(record[:__loader_key], record) }
# Default value is nil
keys.each { |key| fulfill(key, nil) unless fulfilled?(key) }
end
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment