Graphql(graphql-ruby)でネストした関連テーブルをincludesするとキャッシュが効かずN+1になってしまう件について
結論
railsのincludesを使ってネストした関連をキャッシュしていても、has_many through関連を使用して関連テーブルを取得しようとするとクエリを発行してしまう。
例: User.includes(user_tags: :tag).where(user_tags: {tag_id: tag_id})
userから直接has_many through関連先を取得せず、中間テーブルを経由して取得するようにする
例:
user.tags ❌
user.user_tags.map(&:tag) ⭕️
テーブル構成
このようなテーブルがあったとします。
・usersテーブル
id | name |
---|---|
1 | 太郎 |
2 | 花子 |
・user_tagsテーブル(中間テーブル)
id | user_id | tag_id |
---|---|---|
1 | 1 | 1 |
2 | 1 | 2 |
2 | 2 | 3 |
・tagsテーブル
id | name |
---|---|
1 | 高身長 |
2 | 美形 |
2 | 黒髪 |
この場合各モデルの関連はざっくり以下のようになるかと思います。
class User < ActiveRecord::Base
has_many :user_tags, dependent: :destroy
has_many :tags, through: :user_tags
# ユーザー一覧をタグで絞り込むためにUserモデルにsearch_by_tagスコープを定義します。
scope :search_by_tag, -> (tag_id) {
includes(user_tags: :tag).where(user_tags: {tag_id: tag_id})
}
end
class UserTag < ActiveRecord::Base
belongs_to :user
belongs_to :tag
end
class User < ActiveRecord::Base
has_many :user_tags, dependent: :destroy
end
graphql側の実装
module Types
class QueryType < Types::BaseObject
field :users, [Types::UserType], null: true, description: "ユーザー一覧" do
argument :tag_id, Int, required: false, description: "タグID"
end
def users(tag_id:)
User.search_by_tag(tag_id)
end
end
end
module Types
class UserType < Types::BaseObject
field :id, ID, null: false
field :name, String, null: true
field :tags, [Types::TagType], null: true
end
end
module Types
class TagType < Types::BaseObject
field :id, ID, null: false
field :name, String, null: true
end
end
フロントからの呼び出し
graphiqlなどで以下のqueryを実行します。
query {
users(tagId: 1) {
id
name
tags {
id
name
}
}
}
問題と原因
この時問題なくresponseが返ってきますが、 なぜかN+1が発生してしまい、userの数だけクエリが多く発行されてしまいます。
user.rbのscope内でincludesをしているはずなのになぜ??と疑問に感じるかと思います。
原因は関連テーブルをrailsのアソシエーションであるhas_many :tags, through: :user_tags
を使用している点にあります。
実は以下のquery_type.rbで呼び出された時点では問題なくキャッシュされており、正しい関連テーブルの呼び出し方を行えばキャッシュが使用されるためクエリが発行されることはありません。
def users(tag_id:)
User.search_by_tag(tag_id)
end
問題がある箇所は以下のuser_type.rbです。
module Types
class UserType < Types::BaseObject
field :id, ID, null: false
field :name, String, null: true
field :tags, [Types::TagType], null: true
end
end
ここではuserに関連するtagsを field :tags, [Types::TagType], null: true
として定義しています。
しかし、これは実際のコードとしては以下と同様の内容です。
users = User.search_by_tag(tag_id)
users.map(&:tags) # <- userから直接tagsを取得しようとしている
これを実行するとキャッシュしたtagsではなく新規のクエリを発行してtagsを取得しようとしてします。
そのためこれを解決するためにはhas_many through関連を使用せずに関連テーブルであるuser_tagsをtypeに追加しuser_tag_type.rbを経由してtagsを取得する必要がある。
追加・修正する実装
追加
module Types
class UserTagType < Types::BaseObject
field :id, ID, null: false
field :tags, [Types::TagType], null: true
end
end
修正
module Types
class UserType < Types::BaseObject
field :id, ID, null: false
field :name, String, null: true
field :user_tags, [Types::UserTagType], null: true
end
end
フロントからの呼び出し方
query {
user(tagId: 1) {
id
name
user_tags {
tags {
id
name
}
}
}
}
どうしてもuser_tagsの階層を入れたくない場合は以下の実装でいけるかも(未確認)
module Types
class UserType < Types::BaseObject
field :id, ID, null: false
field :name, String, null: true
field :tags_by_through, [Types::TagType], null: true
end
end
class User < ActiveRecord::Base
has_many :user_tags, dependent: :destroy
has_many :tags, through: :user_tags
# ユーザー一覧をタグで絞り込むためにUserモデルにsearch_by_tagスコープを定義します。
scope :search_by_tag, -> (tag_id) {
includes(user_tags: :tag).where(user_tags: {tag_id: tag_id})
}
def tags_by_through # <- 追加
user_tags.map(&:tag)
end
end