I have a Rails 7 GraphQL API, and I’m using the graphql-batch custom loaders to handle some n+1 query issues.
In my GraphQL type definition, I have two fields, has_upcoming_schedule
and has_available_spot
.
I want to modify the has_available_spot
field so that it returns nil
unless there are upcoming schedules. Only if the program has available schedules, it should return true
or false
for spots_available
.
I’m not sure how to achieve this, given that I just can’t call one method from another since it’s a promise, and I’m not sure if calling a loader from inside another loader callback will get executed without n+1s.
module Types
# ...
field :schedules, [ScheduleType], null: true
field :published_schedules, [ScheduleType], null: true
field :has_upcoming_schedule, Boolean, null: false
field :has_available_spot, Boolean, null: true
def has_upcoming_schedule
Loaders::AssociationExists.for(Program, :schedules, :published, :upcoming).load(object)
end
def has_available_spot
Loaders::AssociationExists.for(Program, :schedules, :published, :available_spots).load(object)
end
end
Here’s my custom loader (I don’t think it’s relevant but still):
# Loader for checking if an association exists without N+1s when called from a GraphQL field.
#
# DISCLAIMER: It does NOT work with polymorphic associations or has_many :through associations.
#
# Example usage in `BookType`:
#
# # Example with a scoped association:
# def has_kept_comments
# Loaders::AssociationExists.for(Book, :kept_published_comments).load(object)
# end
#
# # You can also add more scopes:
# def has_kept_comments
# Loaders::AssociationExists.for(Book, :published_comments, :kept).load(object)
# end
module Loaders
class AssociationExists < GraphQL::Batch::Loader
def initialize(model, association_name, *scope_names)
super()
@model = model
@association_name = association_name
@scope_names = scope_names
validate_association_exists
validate_scopes_exist
validate_not_has_many_through
end
def perform(records)
other_klass = reflection.klass
join_field = reflection.join_primary_key
association_query = other_klass.where(join_field => records)
association_query = association_query.merge(reflection.scope) if reflection.scope.present?
scope_names.each do |scope_name|
association_query = association_query.merge(other_klass.send(scope_name))
end
ids_with_association = Set.new(association_query.distinct.pluck(join_field))
records.each do |record|
record_key = record[reflection.active_record_primary_key]
exists = ids_with_association.include?(record_key)
fulfill_record(record, exists)
end
end
private
attr_reader :model, :association_name, :scope_names
def reflection
@reflection ||= model.reflect_on_association(association_name)
end
def validate_association_exists
return if reflection
raise ArgumentError, "No association #{association_name} on #{model}"
end
def validate_scopes_exist
scope_names.each do |scope_name|
unless reflection.klass.respond_to?(scope_name)
raise ArgumentError, "The associated class does not respond to '#{scope_name}'"
end
end
end
def validate_not_has_many_through
return unless reflection.is_a?(ActiveRecord::Reflection::ThroughReflection)
raise ArgumentError, "The association #{association_name} on #{model} "
"is a 'has_many :through' association which is not supported"
end
# Implemented as separate method so it can be overriden in AssociationNotExists to negate:
def fulfill_record(record, exists)
fulfill(record, exists)
end
end
end
1 Answer
I figured it out. I can define the two promises I’m dependent on and wait for them like this:
def has_available_spot
promise_upcoming = has_upcoming_schedule
promise_available_spots = Loaders::AssociationExists.for(Program, :schedules, :published, :with_available_spots).load(object)
Promise.all([promise_upcoming, promise_available_spots]).then do |has_upcoming, has_spots|
has_upcoming ? has_spots : nil
end
end
I checked the sql queries and Graphql-batch is smart enough to not perform the upcoming schedules db query twice. nice