【GraphQL Ruby】AssociationのN+1対策(Dataloader)

## はじめに
過不足なく必要なデータのみを取得することが可能な `GraphQL` 。必要に応じて「親レコードのみ取得できればよい」「子レコードも一緒にまとめて取得したい」といった使い分け可能な点が魅力ですが、それゆえにうっかりN + 1問題を引き起こさないように対策が必要となってきます。
今回は `GraphQL::Dataloader`を用いてアソシエーション部分のN + 1問題の対策を行なってみました。

## 用意したアソシエーション
`projects` テーブルから見て`categories`を親レコード、`tasks`を子レコードとして持ち、`teams`とは中間テーブル`team_projects`を挟んで「多対多」の構成とします。

```ruby
class Project < ApplicationRecord
  belongs_to :category
  has_many :tasks
  has_many :team_projects
  has_many :teams, through: :team_projects
end

class Category < ApplicationRecord
  has_many :projects
end

class Task < ApplicationRecord
  belongs_to :project
end

class TeamProject < ApplicationRecord
  belongs_to :team
  belongs_to :project
end

class Team < ApplicationRecord
  has_many :team_projects
  has_many :projects, through: :team_projects
end
```

ER図で表すと以下のようになります。
```mermaid
erDiagram
    categories ||--o{ projects : "category has_many projects"
    projects ||--o{ tasks : "project has_many tasks"
    projects ||--o{ team_projects : "project has_many team_projects"
    teams ||--o{ team_projects : "team has_many team_projects"

    categories {
        int id PK
        string name
    }

    projects {
        int id PK
        string name
        int category_id FK
    }

    tasks {
        int id PK
        string name
        int project_id FK
    }

    teams {
        int id PK
        string name
    }

    team_projects {
        int team_id FK
        int project_id FK
    }

```


## Dataloaderで親子モデル
### belongs_to

`projects` を起点に親レコードの`category`を一緒に取得するパターンを考えます。
```graphql
query Projects {
  projects {
    id
    name
    category {
      id
      name
    }
  }
}
```

```ruby:project_type.rb
module Types
  class ProjectType < Types::BaseObject
    field :id, GraphQL::Types::ID, null: false
    field :name, String, null: false
    field :category, Types::CategoryType, null: false # belongs_to
  end
end
```
まずは`GraphQL::Dataloader`を使わず`projects`(全5レコード)を取得します。
下記のように1レコードごとに親の`categories`を取得するクエリが発行されてしまっています。

```sql
  Project Load (0.4ms)  SELECT `projects`.* FROM `projects`
  Category Load (0.3ms)  SELECT `categories`.* FROM `categories` WHERE `categories`.`id` = 1 LIMIT 1
  Category Load (0.3ms)  SELECT `categories`.* FROM `categories` WHERE `categories`.`id` = 2 LIMIT 1
  Category Load (0.2ms)  SELECT `categories`.* FROM `categories` WHERE `categories`.`id` = 3 LIMIT 1
  Category Load (0.1ms)  SELECT `categories`.* FROM `categories` WHERE `categories`.`id` = 4 LIMIT 1
  Category Load (0.2ms)  SELECT `categories`.* FROM `categories` WHERE `categories`.`id` = 5 LIMIT 1
```

それでは、親の`categories` を取得する際に `GraphQL::Dataloader` を使うようにコードを修正します。ドキュメントに掲載されている例をほぼそのまま使用しています。

https://graphql-ruby.org/dataloader/sources.html#example-loading-activerecord-objects-by-id

```ruby:active_record_single_object.rb
class Sources::ActiveRecordSingleObject < GraphQL::Dataloader::Source
  def initialize(model_class)
    @model_class = model_class
  end

  def fetch(ids)
    records = @model_class.where(id: ids)
    # return a list with `nil` for any ID that wasn't found
    ids.map { |id| records.find { |r| r.id == id.to_i } }
  end
end
```

```diff_ruby:project_type.rb
module Types
  class ProjectType < Types::BaseObject
    field :id, GraphQL::Types::ID, null: false
    field :name, String, null: false
    field :category, Types::CategoryType, null: false # belongs_to

+    def category
+      dataloader.with(Sources::ActiveRecordSingleObject, ::Category).load(object.category_id)
+    end
  end
end
```

上記対応後の発行SQLがこちらとなります。親 `categories` を取得するSQLがIN検索で1つにまとめられて、N+1を回避することができました。
```sql
  Project Load (0.2ms)  SELECT `projects`.* FROM `projects`
  Category Load (0.3ms)  SELECT `categories`.* FROM `categories` WHERE `categories`.`id` IN (1, 2, 3, 4, 5)
```

### has_many
`projects` を起点に子レコード `tasks` を取得します。
```graphql
query Projects {
  projects {
    id
    name
    tasks {
      id
      name
    }
  }
}
```
`GraphQL::Dataloader` を使わないと、やはりN+1問題が発生しています。
```sql
  Project Load (0.3ms)  SELECT `projects`.* FROM `projects`
  Task Load (0.3ms)  SELECT `tasks`.* FROM `tasks` WHERE `tasks`.`project_id` = 1
  Task Load (0.3ms)  SELECT `tasks`.* FROM `tasks` WHERE `tasks`.`project_id` = 2
  Task Load (0.2ms)  SELECT `tasks`.* FROM `tasks` WHERE `tasks`.`project_id` = 3
  Task Load (0.2ms)  SELECT `tasks`.* FROM `tasks` WHERE `tasks`.`project_id` = 4
  Task Load (0.2ms)  SELECT `tasks`.* FROM `tasks` WHERE `tasks`.`project_id` = 5
```
こちらも子レコード取得時に `GraphQL::Dataloader` を利用するように修正します。コードはこちらの記事を参考にさせていただきました。

https://qiita.com/kyntk/items/7ff8d312480ec913f049#%E3%83%9E%E3%83%AB%E3%83%81%E3%83%AC%E3%82%B3%E3%83%BC%E3%83%89%E3%82%92%E5%8F%96%E5%BE%97

```ruby:active_record_multiple_object.rb
class Sources::ActiveRecordMultipleObject < GraphQL::Dataloader::Source
  def initialize(model_class, foreign_key_column_name)
    @model_class = model_class
    @foreign_key_column_name = foreign_key_column_name
  end

  def fetch(ids)
    records = @model_class.where({ @foreign_key_column_name => ids })
                          .group_by { |record| record[@foreign_key_column_name] }
    ids.map { |id| records[id] || [] }
  end
end
```

```diff_ruby:project_type.rb
module Types
  class ProjectType < Types::BaseObject
    field :id, GraphQL::Types::ID, null: false
    field :name, String, null: false
    field :tasks, [Types::TaskType], null: true # has_many

+    def tasks
+      dataloader.with(Sources::ActiveRecordMultipleObject, ::Task, :project_id).load(object.id)
+    end
  end
end
```

この場合も、`GraphQL::Dataloader` を使用することによって下記のようにN+1問題を回避できるようになりました。
```sql
  Project Load (0.3ms)  SELECT `projects`.* FROM `projects`
  Task Load (0.2ms)  SELECT `tasks`.* FROM `tasks` WHERE `tasks`.`project_id` IN (1, 2, 3, 4, 5)
```


## Dataloaderで「多対多(N:N)」モデル
さて、「多対多」関係にある `teams` はどのように取得するべきでしょうか。
```graphql
query Projects {
  projects {
    id
    name
    teams {
      id
      name
    }
  }
}
```
そのまま取得しにかかるとやはりN+1問題が発生します。
```sql
  Project Load (0.3ms)  SELECT `projects`.* FROM `projects`
  Team Load (0.2ms)  SELECT `teams`.* FROM `teams` INNER JOIN `team_projects` ON `teams`.`id` = `team_projects`.`team_id` WHERE `team_projects`.`project_id` = 1
  Team Load (0.2ms)  SELECT `teams`.* FROM `teams` INNER JOIN `team_projects` ON `teams`.`id` = `team_projects`.`team_id` WHERE `team_projects`.`project_id` = 2
  Team Load (0.2ms)  SELECT `teams`.* FROM `teams` INNER JOIN `team_projects` ON `teams`.`id` = `team_projects`.`team_id` WHERE `team_projects`.`project_id` = 3
  Team Load (0.2ms)  SELECT `teams`.* FROM `teams` INNER JOIN `team_projects` ON `teams`.`id` = `team_projects`.`team_id` WHERE `team_projects`.`project_id` = 4
  Team Load (0.2ms)  SELECT `teams`.* FROM `teams` INNER JOIN `team_projects` ON `teams`.`id` = `team_projects`.`team_id` WHERE `team_projects`.`project_id` = 5
```

`team_projects` 経由で取得する(`has_many`、`belongs_to` にあたる部分で `GraphQL::Dataloader`を使う)ことでN+1問題は回避できますが、外部キー以外の情報を持っていない`team_projects`をわざわざ取得したくはありません。
```graphql
query Projects {
  projects {
    id
    name
    teamProjects {
      id
      team {
        id
        name
      }
    }
  }
}
```

そこで、`has_many` 用に実装したコードを発展させて、中間テーブル取得時に関連先を同時にキャッシュし、最終的には関連先のレコードを返すクラスを新たに実装します。


```ruby:active_record_multiple_through_object.rb
class Sources::ActiveRecordMultipleThroughObject < GraphQL::Dataloader::Source
  def initialize(model_class, foreign_key_column_name, through_class)
    @model_class = model_class
    @foreign_key_column_name = foreign_key_column_name
    @through_class = through_class
  end

  def fetch(ids)
    association_name = @model_class.name.underscore
    # 中間テーブル取得時に関連先もキャッシュ
    records = @through_class.where({ @foreign_key_column_name => ids }).preload(association_name)
                            .group_by { |record| record[@foreign_key_column_name] }
    # キャッシュした関連先レコードを返す
    ids.map { |id|
      (records[id] || []).map {|record|
        record.public_send association_name
      }
    }
  end
end
```

```diff_ruby:project_type.rb
module Types
  class ProjectType < Types::BaseObject
    field :id, GraphQL::Types::ID, null: false
    field :name, String, null: false
    field :teams, [Types::TeamType], null: true # has_many througth

+    def teams
+      dataloader.with(Sources::ActiveRecordMultipleThroughObject, ::Team, :project_id, ::TeamProject).load(object.id)
+    end
  end
end

```

上記対応後、`teams` を取得するSQLが1つにまとめらて、N+1問題を回避することができました。
```sql
  Project Load (0.2ms)  SELECT `projects`.* FROM `projects`
  TeamProject Load (0.3ms)  SELECT `team_projects`.* FROM `team_projects` WHERE `team_projects`.`project_id` IN (1, 2, 3, 4, 5)
  ↳ app/graphql/sources/active_record_multiple_object.rb:9:in `group_by'
  Team Load (0.3ms)  SELECT `teams`.* FROM `teams` WHERE `teams`.`id` IN (1, 2, 3, 5)
  ↳ app/graphql/sources/active_record_single_object.rb:9:in `block in fetch'
```

## まとめ
過不足なく必要なデータのみを取得できるのが`GraphQL`の良いところですが、N+1問題が発生してしまうと結局のところパフォーマンスの低下につながってしまいます。`GraphQL`の強みを存分に活かすたには、`GraphQL::Dataloader`等を活用したN+1問題の対策が必要不可欠と言えるでしょう。

## 参考
[GraphQL - Sources](https://graphql-ruby.org/dataloader/sources.html#example-loading-activerecord-objects-by-id)
[GraphQL RubyのDataloaderを使ってみる](https://qiita.com/kyntk/items/7ff8d312480ec913f049)