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

