はじめに
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/