はじめに
UserとPostが1対多で結びついているような状況で、userとpostを一度 に取得することを想定しています。
記事について
graphql-ruby初学者向けの記事を書いていきます。
関連記事
- graphql-batchでN+1を解消してみた(現在の記事)
- 無限ページネーション
- 個人開発のcodegen.ymlの設定について考えてみた
- ファイルアップロード機能を実装してみた
使用技術
ruby 3.1.2 rails 7.0.3.1 graphql 2.0.13 graphql-batch 0.5.1
N+1が発生するコード
has_many :posts
belongs_to :user
module ObjectTypes class User < Types::BaseObject field :id, ID, null: false field :name, String, null: false # userのオブジェクトタイプにpostsを定義することで # user一覧および紐づくpost一覧を取得することができる # しかし、この構成にするとN+1が発生する field :posts, [ObjectTypes::Post], null: false end end
module ObjectTypes class Post < Types::BaseObject field :id, ID, null: false field :title, String, null: false end end
module Queries class Users < Queries::BaseQuery type [ObjectTypes::User], null: false def resolve User.all end end end
users一覧取得
{ users { id name posts { id title } } }
上記のqueryをGraphiQLで実行してみると、N+1が発生しました。
User Load (0.4ms) SELECT "users".* FROM "users" Post Load (0.2ms) SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = $1 [["user_id", 1]] Post Load (0.2ms) SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = $1 [["user_id", 2]] Post Load (0.1ms) SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = $1 [["user_id", 3]]
では解消していきます!
graphql-batch導入しN+1を解消する
N+1解消用のgemです! https://github.com/Shopify/graphql-batch
gem 'graphql-batch'
class MyappSchema < GraphQL::Schema mutation(Types::MutationType) query(Types::QueryType) use GraphQL::Batch #この行だけ追加 # 以下省略 end
# fieldのpostsは残したまま、下記メソッド追加 def posts Loaders::AssociationLoader.for(::User, :posts).load(object) end
# graphql-batchのexampleのコピペ module Loaders class AssociationLoader < GraphQL::Batch::Loader def self.validate(model, association_name) new(model, association_name) nil end def initialize(model, association_name) super() @model = model @association_name = association_name validate end def load(record) raise TypeError, "#{@model} loader can't load association for #{record.class}" unless record.is_a?(@model) return Promise.resolve(read_association(record)) if association_loaded?(record) super end # We want to load the associations on all records, even if they have the same id def cache_key(record) record.object_id end def perform(records) preload_association(records) records.each { |record| fulfill(record, read_association(record)) } end private def validate unless @model.reflect_on_association(@association_name) raise ArgumentError, "No association #{@association_name} on #{@model}" end end def preload_association(records) ::ActiveRecord::Associations::Preloader.new(records:, associations: @association_name).call end def read_association(record) record.public_send(@association_name) end def association_loaded?(record) record.association(@association_name).loaded? end end end
User Load (0.5ms) SELECT "users".* FROM "users" Post Load (0.3ms) SELECT "posts".* FROM "posts" WHERE "posts"."user_id" IN ($1, $2, $3) [["user_id", 1], ["user_id", 2], ["user_id", 3]]
N+1が解消されました!
処理の流れとしては、userを全て取得し、下記のメソッドで一括preloadしてpostを取得しています。
引数のrecords
はuserオブジェクトの配列、@association_name
は:posts
がそれぞれ格納されています。
def preload_association(records) ::ActiveRecord::Associations::Preloader.new(records:, associations: @association_name).call end
取得したpost一覧に処理を加える
ついでに、postの順序を作成日順に並び替えたい等、処理を追加したいという時はthen
を使います。
下記のようにpostsメソッドを修正すると、作成日順に並び替えることができます。
def posts Loaders::AssociationLoader.for(::User, :posts).load(object).then do |posts| posts.order(created_at: :desc) end end
post一覧と、関連付いたuserを取得する(おまけ)
下記クエリのように、post一覧で関連付くuserを取得したいというような時は、こんな感じで記述すると取得できます。
{ posts { id title user { id name } } }
field :user, ObjectTypes::UserType, null: false def user Loaders::AssociationLoader.for(::Post, :user).load(object) end
会社の紹介
株式会社 mofmof では一緒に働いてくれるエンジニアを募集しています。 興味のある方は是非こちらのページよりお越しください! https://www.mof-mof.co.jp/ https://www.mof-mof.co.jp/recruit/