我想使用graphql-ruby和外部库进行分页
The background
大家都在用GraphQL吗?我个人非常喜欢它,无论是在工作中的产品还是私下开发,我都主要使用Ruby on Rails和graphql-ruby。GraphQL在遇到N+1查询的时候确实会有问题,但在一般的has_many关系中,可以使用goldiloader解决,而在复杂情况下,使用graphql-batch编写批处理代码通常可以避免这个问题。
关于GraphQL的问题,由于许多博客都介绍了Relay风格(基于游标)的分页方式,所以默认情况下,将其引入现有的基于偏移的应用程序可能会遇到困难。对此,有很多解决方法被提出。
然而,就我这个案例而言,我已经在项目中使用pagy作为页面标记的库。如果使用kaminari,可以通过引入名为graphql-kaminari_connection的Gem来实现kaminari的分页功能。然而,pagy没有这样的Gem,因此一开始我是参考graphql-kaminari_connection,在GraphQL中使用pagy进行分页。这个Gem非常简单,因此我可以很容易地进行模仿。但当时我必须自己定义参数如page和items,并在解析器中编写分页部分(现在不清楚现状如何)。
为了尽可能简洁地介绍如何在graphql-ruby中引入基于偏移的分页功能,我们在这里稍微深入一些,可能有点不按常规走?同时,将所有繁琐的事情交给外部库pagy来处理。
目标
假设我们有一个名为Test(从名字来看非常随意)的模型。在这种情况下,如果我们要使用graphql-ruby编写Relay风格的解析器,可能有很多写法,但下面的实现是可行的。
module Types
class Tests1Resolver < GraphQL::Schema::Resolver
type Test.connection_type, null: false
def resolve
::Test.all
end
end
end
本次目标是以与上述内容几乎相同的描述,通过具体的实施,即与Types::Test.connection_type相似的描述(本次称为collection_type),实现在pagy下的分页功能。
module Types
class Tests2Resolver < GraphQL::Schema::Resolver
type Test.collection_type, null: false
def resolve
::Test.all
end
end
end
环境 – .
-
- ruby 3.0.1
-
- rails 6.1.3.2
-
- graphql-ruby 1.12.12
- pagy 4.9.0
准备
首先,我们需要安装graphql-ruby和pagy来进行基本设置。由于这方面已经在许多博客中有详细介绍,所以我将简要地写一下。
gem 'graphql'
gem 'pagy'
$ bundle
$ bin/rails generate graphql:install
我认为这样就会在app/graphql的文件夹下生成GraphQL的基本文件集。我希望能在这个基础上进行添加和编辑工作。
实施
我希望在graphql-ruby中实际引入pagy。
定义一个执行pagy处理的类
首先要定义的是实际执行pagy处理的部分实现。对于在控制器中使用pagy的人来说,我认为没有什么特别困难的地方。
module Types
class Pagy
include ::Pagy::Backend
attr_reader :metadata, :collection
def initialize nodes, page: nil, items: nil
@metadata, @collection = paginate nodes, page: page, items: items
end
private
def paginate nodes, page: nil, items: nil
case nodes
when ActiveRecord::Relation then pagy nodes, page: page, items: items
when Array then pagy_array nodes, page: page, items: items
end
end
def params
{}
end
end
end
为了使用Pagy的每个方法,要像在控制器中引入一样,需要包括Pagy::Backend。此外,Pagy不仅为ActiveRecord提供分页功能,还为数组提供分页功能,因此在case语句中判断是ActiveRecord::Relation还是数组,并调用不同的方法来处理。
最后提及的是一个奇怪的params,这是因为在控制器的情况下,pagy需要通过params读取page和items,但这次将从GraphQL参数中提供,因此我们将使用一个空的哈希来处理。
定义一个自动定义参数和字段的类的定义。
通过在graphql-ruby中使用Relay,可以使用Test.connection_type这样的连接类型,即使不特别指定first或after等参数也会自动定义。同样地,我们将使用pagy作为要使用的字段,自动定义page和items。
module Types
class PagyExtension < GraphQL::Schema::FieldExtension
def apply
field.argument :page, Integer, required: false
field.argument :items, Integer, required: false
end
def resolve object:, arguments:, **_rest
args = arguments.dup
page = args.delete :page
items = args.delete :items
obj = yield object, args
Pagy.new obj, page: page, items: items
end
end
end
因为resolve函数中定义的参数会作为arguments传递,所以我们要在取出page和items的同时,将其他参数传递给每个解析器,并将结果传递给之前定义的Types::Pagy。
将pagy的元数据定义为GraphQL对象
在graphql-ruby中,如果使用默认的Relay,您可以获取到像hasNextPage这样的页面信息作为pageInfo。类似地,我们可以定义一个GraphQL对象来存储从pagy方法返回的元数据。
module Types
class Metadata < BaseObject
field :count, Integer, null: false
field :page, Integer, null: false
field :items, Integer, null: false
field :pages, Integer, null: false
field :last, Integer, null: false
field :offset, Integer, null: false
field :from, Integer, null: false
field :to, Integer, null: false
field :prev, Integer, null: true
field :next, Integer, null: true, resolver_method: :object_next
delegate :next, to: :object, prefix: true
end
end
在Pagy中,它会返回元数据作为返回值,其中包含一个名为”next”的实例变量。我想直接调用它,但由于这样会无法区分已存在的Ruby中的”next”,所以它会在日志中显示以下警告和处理方法。
Metadata's `field :next` conflicts with a built-in method, use `resolver_method:` to pick a different resolver method for this field (for example, `resolver_method: :resolve_next` and `def resolve_next`). Or use `method_conflict_warning: false` to suppress this warning.
所以,我們使用resolver_method來更改呼叫方法的名稱,並使用delegate使該方法能直接對應到object。
定義了一个生成动态包装了GraphQL对象的GraphQL对象的类。
标题看起来非常复杂,但正如目标所述,在graphql-ruby中,通过使用collection_type来针对GraphQL对象生成名为TestConnection的动态对象。为了实现类似的功能,我们将定义一个名为TestCollection的对象来创建该类。
module Types
class Collection < BaseObject
def self.create type
Class.new self do
graphql_name "#{type.graphql_name}Collection"
field :collection, [type], null: false
field :metadata, Metadata, null: false
end
end
end
end
self.create函数动态生成继承Types::Collection对象(的类)。生成的类使用Types::Pagy中的pagy方法返回的collection和metadata作为字段进行定义。这里使用Types::Collection而不是Types::BaseObject是为了后续判断之用。
基于基本对象的collection_type实现。
为了使任何对象都能使用,我们将在基础对象中实现一个名为collection_type的类方法。
module Types
class BaseObject < GraphQL::Schema::Object
edge_type_class Types::BaseEdge
connection_type_class Types::BaseConnection
field_class Types::BaseField
def self.collection_type
@collection_type ||= Collection.create self
end
end
end
Types::Collection.create这个方法接受一个self参数(代表自身的类),但是要知道,collection_type方法本身是一个继承自Types::BaseObject的类(比如Types::Test)会调用的。也就是说,我们可以知道self参数中会传入Types::Test这样的对象。这样一来,就可以动态地生成包含Types::Test的新类。
拡大基地领域
使用上述的Types::PagyExtension,在最后来扩展Types::BaseField,以实现自动定义参数。
module Types
class BaseField < GraphQL::Schema::Field
argument_class Types::BaseArgument
def initialize **kwargs, &block
super
return unless kwargs[:type].is_a? Class
return unless kwargs[:type] < Collection
extension PagyExtension
end
end
end
在这里,我们判断kwargs[:type]是否是Types::Collection。这样,只有在需要的情况下(即继承了Types::Collection的类),Types::PagyExtension中定义的page和items才会自动定义。
这样一来,实施就完成了。
考试 shì)
我使用随机数据在graphiql-rails中进行了测试。

虽然不太需要同时使用游标基准和偏移量基准,但出于样本实现的目的,我还是同时写了两种。源代码已经上传到GitHub,如果想参考的话,请务必查看。
总结
这次可能有点邪门,但我故意使用现有的库来实现分页。顺便说一下,偏移基础的分页在官方文档中也有提到,因其本质的原因,在删除或添加数据时难免会产生偏移。但我认为在管理界面等很多情况下,这不会成为大问题,而且pagy还有一个叫做pagy-cursor的扩展实现,可以支持基于游标的分页,所以可以尝试结合这些来实现(未经验证)基于游标的分页。