8
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

graphql-rubyで外部ライブラリを使ってページネーションしたい

Posted at

背景

みなさんGraphQL使ってますか?私は個人的に好きで仕事のプロダクトでももっぱら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は非常にシンプルな故にサクッと模倣できたのですが、当時は自分でpageitemsといったargumentを定義したりリゾルバーでページネーション部分を書かないといけませんでした(今はどうなっているか分かりません)。

そこで今回は少し踏み込んでるので邪道?かもしれませんが、面倒なことは全て外部ライブラリのpagyに任せつつなるべく簡潔にgraphql-rubyにオフセットベースのページネーションを導入する方法を紹介したいと思います。

目標

今回はTestという(名前からして超適当な)モデルがあると想定します。その際graphql-rubyでRelayスタイルのリゾルバを記述する場合、色々書き方はあるかと思いますが、以下のような実装が考えられます。

app/graphql/types/tests1_resolver.rb
module Types
  class Tests1Resolver < GraphQL::Schema::Resolver

    type Test.connection_type, null: false

    def resolve
      ::Test.all
    end
  end
end

今回は上記の内容とほぼ同様の記述でpagyでのページネーションができるように、具体的にはTypes::Test.connection_typeと同様の記述(今回はcollection_typeとします)で下記のように実装できることが目標です。

app/graphql/types/tests2_resolver.rb
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-rubypagyをインストールして基本的なセットアップを行います。この辺はすでに多くのブログで紹介されているのでざっくりと書いていきます。

Gemfile
gem 'graphql'
gem 'pagy'
$ bundle
$ bin/rails generate graphql:install

これでapp/graphql配下にGraphQLの基本的なファイル群が生成されたと思います。これを基に追加・編集していきたいと思います。

実装

では実際にpagygraphql-rubyに導入していきたいと思います。

pagyの処理を行うクラスの定義

まず最初に定義するのは実際にpagyの処理を行う部分の実装です。コントローラーでpagyを使ったことがある方にとっては特に難しいところはないと思います。

app/graphql/types
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をインクルードしてます。またpagyActiveRecordだけでなくArrayに対してもページネーション機能を提供してくれているのでcase文にてActiveRecord::RelationArrayを判定して各々に対して異なるメソッドを呼んでいます。

最後に奇妙なparamsですが、これはコントローラーの場合pagyparamsにあるpageitemsを読み込むために呼ばれるのですが、今回はGraphQLの引数から提供される予定なので空のハッシュを置いて対処してます。

自動的に引数とフィールドを定義するクラスの定義

graphql-rubyのRelayではTest.connection_typeのようにコネクションタイプを用いることで、特にfirstとかafterを記述しなくても自動的に引数が定義されるかと思います。それと同様に今回はpagyで使うフィールドとしてpageitemsが自動で定義されるようにします。

app/graphql/types/pagy_extension.rb
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として渡されるので、pageitemsを取り出しつつ、各リゾルバーにそれ以外の引数を渡し、その結果を先程定義したTypes::Pagyに渡しています。

pagyのメタデータをGraphQLオブジェクトとして定義

graphql-rubyではデフォルトのRelayを用いるとpageInfoとしてhasNextPageなどのページ情報が取得できるかと思います。それと同様にpagyメソッドから返り値として得られるメタデータを格納するためのGraphQLのオブジェクトを定義します。

app/graphql/types/metadata.rb
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を用いて呼び出すメソッド名を変更し、そのメソッド名をそのままobjectに対して呼べるようにdelegateを使っています。

動的にGraphQLオブジェクトをラップしたGraphQLオブジェクトを生成するクラスの定義

タイトルがとってもややこしいですが、目標でも書いたようにgraphql-rubyではGraphQLオブジェクトに対してcollection_typeを用いることでTestConnectionというオブジェクトが動的に生成されます。これと同様のことができるようにTestCollectionのようなオブジェクトを作成するクラスを定義します。

app/graphql/types/collection.rb
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メソッドの返り値であるcollectionmetadataをフィールドとして定義しています。ここでTypes::BaseObjectではなくselfつまりTypes::Collectionを継承させてるのは後々判定に用いるためです。

ベースオブジェクトにcollection_typeの実装

どのオブジェクトでも使えるようにベースオブジェクトにクラスメソッドとしてcollection_typeを実装します。

app/graphql/types/base_object.rb
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.createself(自身のクラス)を渡していますが、そもそもcollection_typeメソッドはTypes::BaseObjectを継承したクラス、例えば目標のようにTypes::Testなどで呼ばれます。つまり、このselfにはTypes::Testなどのオブジェクトが入ってくることが分かります。こうしてTypes::Testを含んだ新たなクラスを動的に生成することが可能となります。

ベースフィールドの拡張

最後に上記で作成したTypes::PagyExtensionを用いて自動的に引数が定義されるようにTypes::BaseFieldを拡張します。

app/graphql/types/base_field.rb
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が自動で定義されるようになります。

これで実装は完了です。

テスト

テストは適当なデータを作成してgraphiql-railsで行いました。

Screen Shot 2021-06-23 at 1.23.33.png

カーソルベースとオフセットベースの両方を併用したいということはないでしょうがサンプル用の実装なので両方書いておきました。ソースコードはgithubにアップロードしてあるので参考にしたい方はぜひ。

まとめ

今回は少し邪道かもしれませんが、あえてページネーションを既存のライブラリで行えるように実装しました。ちなみにオフセットベースのページネーションは公式のドキュメントにもあるように、その性質上どうしてもデータの削除や追加でズレてしまうことがあるかと思います。しかし管理画面など多くの場合はそれほど大きな問題にはならないかと思いますし、pagyにもカーソルベースのページネーションが行えるようpagy-cursorという拡張実装もあるようなので、これらを組み合わせることで(未検証ですが)カーソルベースのページネーションに対応できるかもしれません。

参考

8
1
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?