Rails graphql-batch: Dependent fields that use loaders

Rails graphql-batch: Dependent fields that use loaders


0

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
1


1

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



Leave a Reply

Your email address will not be published. Required fields are marked *