You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Rails graphql-batch:使用加载器的依赖字段处理问题

问题描述

我有一个Rails 7 GraphQL API,使用graphql-batch自定义加载器处理N+1查询问题。GraphQL类型定义中有两个字段:has_upcoming_schedule(判断是否有即将到来的日程)和has_available_spot(判断是否有可用名额)。现在需要修改has_available_spot字段,使其仅在存在即将到来的日程时返回true/false,否则返回nil。由于加载器返回Promise,无法直接调用另一个字段的方法,也不确定在加载器回调中调用加载器是否会避免N+1问题,不知道如何实现。

相关代码:

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

自定义加载器代码:

# 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
解决方案

要实现需求且避免N+1问题,有两种可行思路:

思路1:组合两个加载器的Promise结果

利用graphql-batch的Promise特性,在has_available_spot字段方法中先获取has_upcoming_schedule的结果,再根据该结果决定是否返回可用名额的检查结果或nil。这种方式不需要修改加载器,直接在字段层处理逻辑:

修改Types中的has_available_spot方法:

def has_available_spot
  # 先获取是否有即将到来的日程
  upcoming_promise = Loaders::AssociationExists.for(Program, :schedules, :published, :upcoming).load(object)
  # 再获取是否有可用名额
  available_promise = Loaders::AssociationExists.for(Program, :schedules, :published, :available_spots).load(object)

  # 组合两个Promise的结果
  GraphQL::Batch.batch do
    has_upcoming = upcoming_promise.sync
    has_upcoming ? available_promise.sync : nil
  end
end

注意:GraphQL::Batch.batch块内可以调用sync获取Promise的同步结果,graphql-batch会确保所有加载器的perform方法被批量执行,不会产生N+1查询。

思路2:自定义加载器同时处理两个条件

如果希望将逻辑封装到加载器中,可以新增一个加载器,同时检查upcoming和available_spots两个条件,直接返回符合要求的结果:

步骤1:新增加载器UpcomingAvailableSpotExists

module Loaders
  class UpcomingAvailableSpotExists < GraphQL::Batch::Loader
    def initialize(model, association_name)
      super()
      @model = model
      @association_name = association_name

      validate_association_exists
      validate_not_has_many_through
    end

    def perform(records)
      other_klass = reflection.klass
      join_field = reflection.join_primary_key

      # 先查询有即将到来日程的记录ID
      upcoming_ids = Set.new(
        other_klass
          .where(join_field => records)
          .merge(reflection.scope || other_klass.all)
          .merge(other_klass.published)
          .merge(other_klass.upcoming)
          .distinct
          .pluck(join_field)
      )

      # 再查询有即将到来且有可用名额的记录ID
      upcoming_available_ids = Set.new(
        other_klass
          .where(join_field => records)
          .merge(reflection.scope || other_klass.all)
          .merge(other_klass.published)
          .merge(other_klass.upcoming)
          .merge(other_klass.available_spots)
          .distinct
          .pluck(join_field)
      )

      records.each do |record|
        record_key = record[reflection.active_record_primary_key]
        if upcoming_ids.include?(record_key)
          fulfill(record, upcoming_available_ids.include?(record_key))
        else
          fulfill(record, nil)
        end
      end
    end

    private

    attr_reader :model, :association_name

    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_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
  end
end

步骤2:修改Types中的has_available_spot方法

def has_available_spot
  Loaders::UpcomingAvailableSpotExists.for(Program, :schedules).load(object)
end

两种思路对比

  • 思路1更灵活,无需新增加载器,适合快速实现需求;
  • 思路2将逻辑封装到加载器中,代码更整洁,适合需要复用该逻辑的场景。

两种方式都能保证查询被批量执行,不会产生N+1问题。


内容的提问来源于stack exchange,提问作者Leticia Esperon

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.10 22:05:39