GraphQL アンチパターン - 孫煩悩 -

この記事は [GraphQL Advent Calendar 2020](https://qiita.com/advent-calendar/2020/graphql) 7 日目の記事です。
前回の記事は @indigolain さんの [クエリ結果を軸としたGraphQLのエラーハンドリング](https://indigolain.hatenablog.com/entry/2020/12/06/065823) でした。

## 概要

孫の面倒を見すぎると分岐が大変になるので、Loader を活用して宣言的に書くとよさそう、という話です。
例としては Ruby (graphql-ruby) で示しますが、考え方は他の言語、ライブラリでも同じになるはずではないかと考えています。

## 詳細

Tweet にいいねする機能があるとします。

Tweet 1-* Like *-1 User

このとき、タイムラインで Tweet を並べつつ、各々の Tweet を自分がいいねしたかどうかを表示するとします。

```graphql
type Query {
  timelineTweets: [TimelineTweet!]!
}

type TimelineTweet {
  id: ID!
  liked: Boolean!
}
```

一般的に REST API を実装するように、最初に必要なものすべてを取ってくるアプローチを考えると、例えば次のように JOIN すると一発で全部取ってこれます。

Ruby (ActiveRecord) がわからない方向けに、どういう SQL になるかも書いてあります。

```ruby
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](https://graphql-ruby.org/queries/lookahead.html) という方法で、どの field が要求されているかをチェックすることが可能です。

```ruby
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](https://github.com/Shopify/graphql-batch) を使いますが、これも他の言語にも loader/data loader/batch loader といった名前で調べると何かライブラリがあるはずです。

```ruby
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 から分岐が消えます。

```ruby
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` すればそのまま実行可能です。

```ruby
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}]}}
```