この記事は GraphQL Advent Calendar 2020 7 日目の記事です。
前回の記事は @indigolain さんの クエリ結果を軸としたGraphQLのエラーハンドリング でした。
概要
孫の面倒を見すぎると分岐が大変になるので、Loader を活用して宣言的に書くとよさそう、という話です。
例としては Ruby (graphql-ruby) で示しますが、考え方は他の言語、ライブラリでも同じになるはずではないかと考えています。
詳細
Tweet にいいねする機能があるとします。
Tweet 1-* Like *-1 User
このとき、タイムラインで Tweet を並べつつ、各々の Tweet を自分がいいねしたかどうかを表示するとします。
type Query {
timelineTweets: [TimelineTweet!]!
}
type TimelineTweet {
id: ID!
liked: Boolean!
}
一般的に REST API を実装するように、最初に必要なものすべてを取ってくるアプローチを考えると、例えば次のように JOIN すると一発で全部取ってこれます。
Ruby (ActiveRecord) がわからない方向けに、どういう SQL になるかも書いてあります。
class TimelineTweet < GraphQL::Schema::Object
field :id, ID, null: false
field :liked, Boolean, null: false
def liked
# sqlite だと bool がないので変換
object.liked == 1
end
end
class QueryType < GraphQL::Schema::Object
field :timeline_tweets, '[TimelineTweet]', null: true
def timeline_tweets
# SELECT tweets.*, (likes.id IS NOT NULL) AS liked FROM "tweets" LEFT OUTER JOIN "likes" ON "likes"."tweet_id" = "tweets"."id" WHERE ("likes"."user_id" = ? OR "likes"."user_id" IS NULL) [["user_id", 1]]
Tweet.left_outer_joins(:likes).select("tweets.*, (likes.id IS NOT NULL) AS liked").merge(Like.where(user_id: [nil, context[:current_user].id]))
end
end
class Schema < GraphQL::Schema
query QueryType
end
しかし、GraphQL では、クライアントのクエリを見て必要なデータが変わってきます。
クエリによっては liked
は必要かもしれませんし、必要でないかもしれません。
liked
が必要かどうかは、例えば graphql-ruby では lookahead という方法で、どの field が要求されているかをチェックすることが可能です。
class QueryType < GraphQL::Schema::Object
field :timeline_tweets, '[TimelineTweet]', null: true, extras: [:lookahead]
def timeline_tweets(lookahead:)
# SELECT tweets.*, (likes.id IS NOT NULL) AS liked FROM "tweets" LEFT OUTER JOIN "likes" ON "likes"."tweet_id" = "tweets"."id" WHERE ("likes"."user_id" = ? OR "likes"."user_id" IS NULL) [["user_id", 1]]
if lookahead.selects?(:liked)
Tweet.left_outer_joins(:likes).select("tweets.*, (likes.id IS NOT NULL) AS liked").merge(Like.where(user_id: [nil, context[:current_user].id]))
else
Tweet.all
end
end
end
分岐が苦しいですね。
このように、親が子や孫の面倒を見すぎるとパラメータのバリエーションが豊富で複雑に絡み合った REST API のようになってしまいます。
GraphQL では、このような場合には立ち止まって loader として切り出すことを考えたほうがよいです。
ここでは graphql-batch を使いますが、これも他の言語にも loader/data loader/batch loader といった名前で調べると何かライブラリがあるはずです。
class LikesLoader < GraphQL::Batch::Loader
def initialize(user_id)
@user_id = user_id
end
def perform(tweet_ids)
Like.where(tweet_id: tweet_ids, user_id: @user_id).each {|l| fulfill(l.tweet_id, true) }
tweet_ids.each {|id| fulfill(id, false) unless fulfilled?(id) }
end
end
class TimelineTweet < GraphQL::Schema::Object
field :id, ID, null: false
field :liked, Boolean, null: false
def liked
LikesLoader.for(context[:current_user].id).load(object.id)
end
end
こうすれば、timeline_tweets から分岐が消えます。
class QueryType < GraphQL::Schema::Object
field :timeline_tweets, '[TimelineTweet]', null: true
def timeline_tweets
Tweet.all
end
end
まとめ
GraphQL resolver の実装が難しくなってきたら、孫の面倒を見すぎていないか考えてみましょう。
とはいえ、GraphQL において、親がどの程度まで孫の面倒を見るべきかの線引は難しいです。
孫の面倒は見ないほうがいいと必ずしも言い切ることはできません。
例えば今回の例でも、Tweet.all
は暗黙的に TimelineTweet の id を読み込んでいると言えます。
私もまだ明確に言語化できてはいませんが、一つの孫煩悩の指標として「JOIN クエリ」があるのではないかと考えています。
コード全体
保存して $ ruby foo.rb
すればそのまま実行可能です。
require 'bundler/inline'
gemfile do
source 'https://rubygems.org'
gem 'graphql'
gem 'activerecord', require: 'active_record'
gem 'sqlite3'
gem 'graphql-batch'
end
require 'logger'
ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:')
ActiveRecord::Base.logger = Logger.new(STDOUT)
ActiveRecord::Schema.define do
create_table :tweets, force: true do |t|
end
create_table :likes, force: true do |t|
t.references :tweet
t.references :user
end
create_table :users, force: true do |t|
end
end
class Tweet < ActiveRecord::Base
has_many :likes
end
class Like < ActiveRecord::Base
belongs_to :tweet
belongs_to :user
end
class User < ActiveRecord::Base
has_many :likes
end
user = User.create!
tweet_1 = Tweet.create!
tweet_2 = Tweet.create!
tweet_3 = Tweet.create!
Like.create!(user: user, tweet: tweet_1)
Like.create!(user: user, tweet: tweet_3)
class LikedLoader < GraphQL::Batch::Loader
def initialize(user_id)
@user_id = user_id
end
def perform(tweet_ids)
Like.where(tweet_id: tweet_ids, user_id: @user_id).each {|l| fulfill(l.tweet_id, true) }
tweet_ids.each {|id| fulfill(id, false) unless fulfilled?(id) }
end
end
class TimelineTweet < GraphQL::Schema::Object
field :id, ID, null: false
field :liked, Boolean, null: false
def liked
LikedLoader.for(context[:current_user].id).load(object.id)
end
end
class QueryType < GraphQL::Schema::Object
field :timeline_tweets, '[TimelineTweet]', null: true
def timeline_tweets
Tweet.all
end
end
class Schema < GraphQL::Schema
query QueryType
use GraphQL::Batch
end
result = Schema.execute(<<~GQL, context: {current_user: user})
query {
timelineTweets {
id
liked
}
}
GQL
pp result.as_json
# {"data"=>
# {"timelineTweets"=>
# [{"id"=>"1", "liked"=>true},
# {"id"=>"2", "liked"=>false},
# {"id"=>"3", "liked"=>true}]}}