🔮

雰囲気でgemを使ったことを反省して学びなおす 〜 graphql-batch AssociationLoader編 〜

2025/02/22に公開

はじめに

直近で関わっているプロジェクトで、GraphQLのクエリで発生するN+1問題を解決するために、graphql-batchAssociationLoaderを利用する機会がありました。

既存の類似実装箇所を参考にコードを書いてN+1問題を解決できたものの、「内部でどのような処理が行われているんだろう?」という疑問が出てきたので、関連部分のコードリーディングに挑戦してみました。

この記事では、そのコードリーディングの結果、自分なりに理解した内容をまとめています。

graphql-batchのLoaderを使ったことはあるけど内部で何をやっているのかはよくわかっていない...という自分と同じような方の参考になれば幸いです!

筆者の経験が浅いため、理解が浅い部分や誤っている内容が含まれているかもしれません。

そのような部分がありましたら、是非コメント等でご指摘いただけると嬉しいです🙇‍♂️

GraphQL::Batchとは

公式のREADMEによると...

Provides an executor for the graphql gem which allows queries to be batched.

直訳:クエリをバッチ処理できる graphql gem 用のエクゼキュータを提供します

直訳だとよくわからなかったのですが、主にGraphQLで発生するN+1問題を解決するためのライブラリだと理解してます。

N+1問題の例とGraphQL::Batchによる解決策

version

下記を前提とします。

  • Ruby 3.3.1
  • Rails 7.0.8
  • graphql 2.4.9
  • graphql-batch 0.6.0

N+1問題の具体例

まずはN+1問題を引き起こす💥ために、下記のようなModel、Type、Query、Resolverを用意しました。

Model

UserモデルとPhotoモデルを作成し、1対多の関連を設定します。

# app/models/user.rb
class User < ApplicationRecord
  has_many :photos, dependent: :destroy
end

# app/models/photo.rb
class Photo < ApplicationRecord
  belongs_to :user
end

Type

UserTypeとPhotoTypeを作成し、UserTypeにはposted_photosフィールドを設定して、Userが投稿したPhotoを取得できるようにします。

# app/graphql/types/user_type.rb
module Types
  class UserType < Types::BaseObject
    field :id, ID, null: false
    field :name, String
    field :posted_photos, [Types::PhotoType], null: false

    def posted_photos
      object.photos
    end
  end
end

# app/graphql/types/photo_type.rb
module Types
  class PhotoType < Types::BaseObject
    field :id, ID, null: false
    field :name, String, null: false
    field :posted_by, Types::UserType, null: false

    def posted_by
      object.user
    end
  end
end

Resolvers

すべてのユーザーを取得するQueryを作成します。

# app/graphql/resolvers/all_users.rb
module Resolvers
  class AllUsers < BaseResolver
    type [Types::UserType], null: false

    def resolve
      ::User.all
    end
  end
end

Query

用意したModel、Type、Resolverを利用して、GraphiQLで下記のクエリを実行すると...

query {
  allUsers {
    id
    name
    postedPhotos {
      name
    }
  }
}

Response

クエリの結果はこのようになります。

{
  "data": {
    "allUsers": [
      {
        "id": "1",
        "name": "テストユーザー1",
        "postedPhotos": [
          {
            "name": "カフェでの一枚"
          },
          {
            "name": "美味しい料理"
          },
          {
            "name": "チームミーティング"
          }
        ]
      },
      {
        "id": "2",
        "name": "テストユーザー2",
        "postedPhotos": [
          {
            "name": "山の頂上"
          },
          {
            "name": "お気に入りの場所"
          },
          // 以下略
        ]
      }
    ]
  }
}

発行されるSQL

Railsのログを確認すると、下記のようなSQLが発行されており、N+1問題が発生していることがわかります。

User Load (0.9ms)  SELECT `users`.* FROM `users`
Photo Load (0.3ms)  SELECT `photos`.* FROM `photos` WHERE `photos`.`user_id` = 1
Photo Load (0.2ms)  SELECT `photos`.* FROM `photos` WHERE `photos`.`user_id` = 2
Photo Load (0.1ms)  SELECT `photos`.* FROM `photos` WHERE `photos`.`user_id` = 3
Photo Load (0.1ms)  SELECT `photos`.* FROM `photos` WHERE `photos`.`user_id` = 4
Photo Load (0.1ms)  SELECT `photos`.* FROM `photos` WHERE `photos`.`user_id` = 5

GraphQL::Batchによる解決策

graphql-batch gem の Loaders::AssociationLoaderを利用して、N+1問題を解決します。

GraphQL::Batchの導入

Gemfileに下記を追加して、bundle install を実行します。

Gemfile
gem 'graphql-batch'

schemaの設定を下記のように修正します。

app/graphql/****_schema.rb
class ****Schema < GraphQL::Schema
  mutation(Types::MutationType)
  query(Types::QueryType)

+ use GraphQL::Batch

  # 以下略
end

AssociationLoader

GraphQL::Batchのexamplesディレクトリに、ActiveRecord用のLoaderの例が用意されています。

そのうちの一つであるAssociationLoaderを利用すると、ActiveRecordのアソシエーションを効率的にまとめて読み込むことができます。

今回は、examplesディレクトリにある association_loader.rb の内容そのままで下記のLoaderを作成し、UserTypeのposted_photosフィールドで利用してみます。

app/graphql/loaders/association_loader.rb
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: 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

Type

UserTypeのposted_photosフィールドを下記のように修正します。

AssociationLoaderは、AssociationLoader.for(model, association_name).load(object) という形式で、指定されたモデルの指定された関連をロードできます。

user_type.rb
module Types
  class UserType < Types::BaseObject
    field :id, ID, null: false
    field :name, String
    field :posted_photos, [Types::PhotoType], null: false

    def posted_photos
+     Loaders::AssociationLoader.for(User, :photos).load(object)
-     object.photos
    end
  end
end

発行されるSQL

UserTypeの変更後に下記クエリを再度GraphiQLで実行してみます。

query {
  allUsers {
    id
    name
    postedPhotos {
      name
    }
  }
}

クエリ実行後にRailsのログを確認すると、下記のようなSQLが発行されており、N+1問題が解決されていることがわかります🎊

User Load (0.2ms)  SELECT `users`.* FROM `users`
Photo Load (2.6ms)  SELECT `photos`.* FROM `photos` WHERE `photos`.`user_id` IN (1, 2, 3, 4, 5)

N+1問題が解決されのでめでたしめでたし...という感じなのですが、ここで思ったのです。

AssociationLoaderの中身をコピペしただけで、中身をよく理解しないまま使ってしまってるなと...

ということで、仕組みを少しでも理解するため、無謀にもAssociationLoaderのコードリーディングに挑戦してみることにしました😉

以下はその足掻いた記録です📝

コードリーディング

GraphQL::Batch::Loader.for

Loaders::AssociationLoader.for(User, :photos) を実行した場合の処理の流れを追ってみます。

上記を実行すると、AssociationLoader が継承している GraphQL::Batch::Loader 内に定義されている 下記のfor メソッドが呼び出されます。

https://github.com/Shopify/graphql-batch/blob/main/lib/graphql/batch/loader.rb#L3-L14

Ruby 2.7 以降であれば、委譲記法(...)を利用するdef self.for(...) の部分が実行されます。

https://www.ruby-lang.org/ja/news/2019/12/12/separation-of-positional-and-keyword-arguments-in-ruby-3-0/#:~:text=別の方法として、Ruby 2.6以前との互換性を考慮する必要がなく、かつ引数を一切改変しないのであれば、以下のようにRuby 2.7で新しく導入される委譲構文(...)を利用できます。

それでは、for メソッドの中身を見ていきます。

GraphQL::Batch::Loader.loader_key_for

current_executor.loader(loader_key_for(...)) { new(...) }loader_key_for(...)の部分から見ていきます。

https://github.com/Shopify/graphql-batch/blob/main/lib/graphql/batch/loader.rb#L16-L18
forメソッドで渡された引数 User:photos が、group_args として [User, :photos] にまとめられ、キーワード引数は空のハッシュ {} となります。

また、self はこの場合 Loaders::AssociationLoader になります。

結果として、ローダーキーは
[Loaders::AssociationLoader, {}, [User, :photos]]
となります。

GraphQL::Batch::Loader.current_executor

current_executor.loader(loader_key_for(...)) { new(...) }current_executorの部分を見ていきいます。
https://github.com/Shopify/graphql-batch/blob/main/lib/graphql/batch/loader.rb#L28-L43
current_executorGraphQL::Batch::Executor.current を呼び出して、executor 変数に結果を代入して返しています。

GraphQL::Batch::Executor.current の中身はどうなっているのでしょうか?

GraphQL::Batch::Executor.current

https://github.com/Shopify/graphql-batch/blob/main/lib/graphql/batch/executor.rb#L1-L9

このコードを見てあれ?と思いました。

これまで読んできたコードでThread.current[THREAD_KEY] に何か格納しているような処理を見たことがなかったからです。

Thread.current[THREAD_KEY]が空で、GraphQL::Batch::Executor.current がnilだと、GraphQL::Batch::Loader.current_executorGraphQL::Batch::NoExecutorErrorをraiseするはずですが、Loaders::AssociationLoader.for(User, :photos) を実行した際にそのようなエラーは発生していませんでした。

ということは、Thread.current[THREAD_KEY] は何か格納されているはずです。

Thread.current[THREAD_KEY]の中身を確認してみると、下記の通り、GraphQL::Batch::Executor のインスタンスが格納されていました。

#<GraphQL::Batch::Executor:0x0000ffff956eebd8 
  @loaders={}, 
  @loading=false, 
  @nesting_level=1
>

ここでまた疑問が浮かんできます🤔

Thread.current[THREAD_KEY]GraphQL::Batch::Executor のインスタンスを格納する処理はどこで行われているのでしょうか?

GraphQL::Batch::Executor.start_batch

currentメソッドの近くを嗅ぎ回っていると、それっぽいコードを見つけました。

https://github.com/Shopify/graphql-batch/blob/main/lib/graphql/batch/executor.rb#L15-L18

この部分では、Thread.current[THREAD_KEY] に既存のExecutorがなければ、新しいExecutorインスタンスを作成し、それをThread.current[THREAD_KEY]に格納しています。

それではstart_batchメソッドはどこで呼ばれているのでしょうか?

GraphQL::Batch.batch

ここで呼ばれていました。

https://github.com/Shopify/graphql-batch/blob/main/lib/graphql/batch.rb#L4-L16

batchメソッドがどこで呼ばれているか探しましたが、見つかりません。

どうやら、app/graphql/****_schema.rbuse GraphQL::Batch を書くと、下記のようにGraphQL::Batch.batchのブロック内でLoaderが実行されるようになるようです。

GraphQL::Batch.batch do
  Loaders::AssociationLoader.for(User, :photos)
end

コードを追ってみると...

スキーマにuse GraphQL::Batchを書く → GraphQL::Schema.useが呼ばれる → GraphQL::Batch.useが呼ばれる → GraphQLのクエリ実行時に内部で自動的に GraphQL::Batch.batch が呼ばれるようになる

という流れっぽかったのですが、GraphQL::Batch.useの部分が難しかったので今回はスルーします😇

ここまで読んできてやっとGraphQL::Batch::Loader.current_executor で何が行われているのか、なんとなく理解できました。

今回の事例(Loaders::AssociationLoader.for(User, :photos))でGraphQL::Batch::Loader.current_executorの実行結果を確認すると、下記のようになっていました。

#<GraphQL::Batch::Executor:0x0000ffff956eebd8 
  @loaders={
    [Loaders::AssociationLoader, {}, [User, :photos]] => 
      #<Loaders::AssociationLoader:0x0000ffff9499f068 
        @loader_key=[Loaders::AssociationLoader, {}, [User, :photos]], 
        @executor=#<GraphQL::Batch::Executor:0x0000ffff956eebd8 ...>, 
        @queue=nil, 
        @cache=nil, 
        @model=User, 
        @association_name=:photos
      >
  }, 
  @loading=false, 
  @nesting_level=1
>

GraphQL::Batch::Loader.forの中身current_executor.loader(loader_key_for(...)) { new(...) }でまだ確認していないのはloaderメソッドです。

GraphQL::Batch::Executor#loader

https://github.com/Shopify/graphql-batch/blob/main/lib/graphql/batch/executor.rb#L41-L46

  • @loaders[key] ||= ... の部分
    まず、この loaderメソッドは、与えられたkeyに対して既に作成済みのローダーがあるかどうかを確認します。
    もし既存のローダーがあればそのまま返し、なければyield以降の処理を実行します。
  • yield の部分
    ブロックが渡されている場合、そのブロックを実行します。
    loaderメソッドに渡されているブロック内の処理はnew(...)となっているため、今回の例ではLoaders::AssociationLoader.new(User, :photos).new が実行され、新しいローダーのインスタンスが生成されます。
  • yield.tap do |loader| ... end の部分
    Rubyのtapメソッドは、オブジェクト自身を返す前にブロックを実行し、何らかの処理を行いたいときに使います。
    今回の場合、yield によって生成されたローダーインスタンスを受け取り、そのインスタンスに対して下記の処理を行い、処理後のインスタンスを返します。
    • ローダーインスタンスのexecutorself(現在のExecutorインスタンス)を設定
    • ローダーインスタンスのloader_keykey(今回の例では[Loaders::AssociationLoader, {}, [User, :photos]])を設定

ここまで読んできた内容を確かめるため、下記の通りGraphQL::Batch::Loader.forを実行して、どのような結果が返ってくるのか確認してみます。

GraphQL::Batch.batch do
  Loaders::AssociationLoader.for(User, :photos)
end

結果は下記の通りでした。

#<Loaders::AssociationLoader:0x0000ffff9499f068
  @loader_key=[Loaders::AssociationLoader, {}, [User, :photos]],
  @executor=#<GraphQL::Batch::Executor:0x0000ffff956eebd8
    @loaders={
      [Loaders::AssociationLoader, {}, [User, :photos]] =>
        #<Loaders::AssociationLoader:0x0000ffff9499f068 ...>
    },
    @loading=false,
    @nesting_level=1
  >,
  @queue=nil,
  @cache=nil,
  @model=User,
  @association_name=:photos
>

続いて、AssociationLoader#load について確認していきます。

AssociationLoader#load

Loaders::AssociationLoader.for(User, :photos).load(User.first) を実行した際の処理の流れを追ってみます。
https://github.com/Shopify/graphql-batch/blob/main/examples/association_loader.rb#L14-L18

loadメソッドは、Loaders::AssociationLoader のインスタンスメソッドです。

Loaders::AssociationLoader.for(User, :photos).load(User.first) を実行した際、User.first:photos はまだロードされていない状態ですので、2行目のif文はfalseとなり、3行目のsuperが実行されます。

superは、Loaders::AssociationLoader の親クラスである GraphQL::Batch::Loaderload メソッドを呼び出します。

GraphQL::Batch::Loader#load

https://github.com/Shopify/graphql-batch/blob/main/lib/graphql/batch/loader.rb#L54-L59

load メソッドは、GraphQL::Batch::Loader のインスタンスメソッドです。

引数としてkeyを受け取ります。

今回の例では、Loaders::AssociationLoader.for(User, :photos).load(User.first) のように実行するので、keyはUserモデルのインスタンスになります。

下記の部分について確認してきます。

cache[cache_key(key)] ||= begin
  • cacheは、Promiseインスタンスを格納するハッシュです。
  • cache_key(key)では、AssociationLoadercache_key(record)メソッドが呼び出されるため、今回の例だとUser.first.object_idが返されます。object_idは、各オブジェクトに対して一意な整数を返します。
  • cache[cache_key(key)]nilまたはfalseの場合に右側の値を代入するようになっています。

続いて、下記の部分を確認してきます。

queue << key
  • queueは、keyを格納するための配列です。
  • queue << keyで、指定されたkeyqueueに追加します。

続いて、下記の部分を確認してきます。

::Promise.new.tap { |promise| promise.source = self }
  • ::Promise.newは、新しいPromiseインスタンスを作成します。
  • tapメソッドは、ブロック内でPromiseインスタンスを操作し、操作後のインスタンスを返します。
  • promise.source = selfは、Promisesource属性に現在のLoaderインスタンスを設定します。

以上をまとめると、loadメソッドは、指定されたcache_key(key)に対応するPromiseインスタンスがあればそれを返し、なければqueuekeyを追加した上で新しいPromiseインスタンスを作成して返すようになっています。

Promiseについてはまだ詳しい調査ができておらず、非同期処理の状態や結果を表現するJSのPromiseのRuby版なのかな?くらいのイメージなのですが、難しそうなので深追いせず、ひとまず先を読み進めることにします。
https://github.com/lgierth/promise.rb

Promise.sync

Promise.syncメソッドが呼び出されていそうなのは、下記部分でした(難しかったので自信はない...)。
Promise.syncがどうやって呼び出されるのかについては、graphql-rubyGraphQL::Schema#sync_lazyが関係していそうなのですが、難しそうなので一旦スルーして先を読み進めます😇

https://github.com/Shopify/graphql-batch/blob/main/lib/graphql/batch.rb#L9-L16

GraphQL::Batch.batchのブロック引数は、下記のようになるため、Promise.sync(yield)yieldGraphQL::Batch::Loader#loadの実行結果(Promiseインスタンス)になります。

GraphQL::Batch.batch do
  Loaders::AssociationLoader.for(User, :photos).load(User.first)
end

Promise.syncの内容は下記の通りとなっています。
https://github.com/lgierth/promise.rb/blob/master/lib/promise.rb#L43-L45

今回、syncの引数はPromiseインスタンスですので、条件式はtrueとなり、Promise#syncが実行されます。

Promise#sync

Promise#syncの内容は下記の通りとなっています。
https://github.com/lgierth/promise.rb/blob/master/lib/promise.rb#L85-L92

syncメソッドのレシーバとなっているPromiseインスタンスのstate:pendingですので、Promise#syncPromise#waitを実行します。

AssociationLoader#wait

Promise#waitの内容は下記の通りとなっていますが、
https://github.com/lgierth/promise.rb/blob/master/lib/promise.rb#L131-L139

今回の例では、下記のAssociationLoader#waitが実行されます。
https://github.com/Shopify/graphql-batch/blob/main/lib/graphql/batch/loader.rb#L88-L95

こちらで確認したように、executorAssociationLoaderのインスタンスにセットされているので、if文の条件式はtrueとなり、executor.resolve(self)が実行されます。

selfAssociationLoaderのインスタンスです.

GraphQL::Batch::Executor#resolve

https://github.com/Shopify/graphql-batch/blob/main/lib/graphql/batch/executor.rb#L48-L54

@loadingの初期値はfalseですので、was_loadingはfalseとなります。

その後、GraphQL::Batch::Loaderresolveメソッドが実行されます。

GraphQL::Batch::Loader#resolve

https://github.com/Shopify/graphql-batch/blob/main/lib/graphql/batch/loader.rb#L69-L81

下記の部分について、

return if resolved?

resolved?は下記の通りとなっており、ここで確認したように今回の例ではqueueUserモデルのインスタンスが格納されているため、falseとなります。
よって、if文の条件式はfalseとなり、returnは実行されません。
https://github.com/Shopify/graphql-batch/blob/main/lib/graphql/batch/loader.rb#L97-L99

下記部分で、queueに格納されたUserモデルのインスタンスの配列をload_keysに格納し、queuenilにしています。

load_keys = queue
@queue = nil

その後、load_keysを引数にperformメソッドが実行されます。

AssociationLoader#perform

https://github.com/Shopify/graphql-batch/blob/main/examples/association_loader.rb#L25-L28

performメソッドではまず、preload_associationメソッドが実行されます。

preload_associationメソッドの内容は下記の通りです。
https://github.com/Shopify/graphql-batch/blob/main/examples/association_loader.rb#L38-L40

今回の事例では、引数のrecordsUserモデルのインスタンスが代入されますので、ActiveRecord::Associations::Preloader.new(records: [User.first], associations: :photos).callが実行され、結果は下記のようになります。

[#<ActiveRecord::Associations::Preloader::Association:0x0000ffff7da75fa8
  @associate=true,
  @key_conversion_required=false,
  @klass=
   Photo(id: integer, name: string, url: string, description: string, created_at: datetime, updated_at: datetime, category: integer, user_id: integer),
  @model=User(id: integer, github_login: string, name: string, avatar: string, created_at: datetime, updated_at: datetime),
  @owners=
   [#<User:0x0000ffff7da76ae8
     id: 1,
     github_login: "test_user1",
     name: "テストユーザー1",
     avatar: "https://example.com/avatars/1.jpg",
     created_at: Sun, 16 Feb 2025 13:10:56.924173000 UTC +00:00,
     updated_at: Sun, 16 Feb 2025 13:10:56.924173000 UTC +00:00>],
  @owners_by_key=
   {1=>
     [#<User:0x0000ffff7da76ae8
       id: 1,
       github_login: "test_user1",
       name: "テストユーザー1",
       avatar: "https://example.com/avatars/1.jpg",
       created_at: Sun, 16 Feb 2025 13:10:56.924173000 UTC +00:00,
       updated_at: Sun, 16 Feb 2025 13:10:56.924173000 UTC +00:00>]},
  @preload_scope=nil,
  @preloaded_records=
   [#<Photo:0x0000ffff7da73ac8
     id: 6,
     name: "カフェでの一枚",
     url: "https://example.com/photos/6.jpg",
     description: "カフェでの一枚の説明文です。",
     created_at: Sun, 16 Feb 2025 13:10:56.961609000 UTC +00:00,
     updated_at: Sun, 16 Feb 2025 13:10:56.961609000 UTC +00:00,
     category: "graphic",
     user_id: 1>,
     ... 略 ...
    #<Photo:0x0000ffff7da730c8
      id: 30,
      name: "リモートワーク",
      url: "https://example.com/photos/30.jpg",
      description: "リモートワークの説明文です。",
      created_at: Sun, 16 Feb 2025 13:10:57.034712000 UTC +00:00,
      updated_at: Sun, 16 Feb 2025 13:10:57.034712000 UTC +00:00,
      category: "selfie",
      user_id: 1>]},
  @reflection=
   #<ActiveRecord::Reflection::HasManyReflection:0x0000ffff7ecb8df0
    @active_record=User(id: integer, github_login: string, name: string, avatar: string, created_at: datetime, updated_at: datetime),
    @active_record_primary_key="id",
    @class_name="Photo",
    @foreign_key="user_id",
    @inverse_name=:user,
    @inverse_of=
     #<ActiveRecord::Reflection::BelongsToReflection:0x0000ffff7df18178
      @active_record=
       Photo(id: integer, name: string, url: string, description: string, created_at: datetime, updated_at: datetime, category: integer, user_id: integer),
      @class_name="User",
      @foreign_key="user_id",
      @inverse_name=nil,
      @klass=User(id: integer, github_login: string, name: string, avatar: string, created_at: datetime, updated_at: datetime),
      @name=:user,
      @options={},
      @plural_name="users",
      @scope=nil>,
    @klass=
     Photo(id: integer, name: string, url: string, description: string, created_at: datetime, updated_at: datetime, category: integer, user_id: integer),
    @name=:photos,
    @options={:dependent=>:destroy},
    @plural_name="photos",
    @scope=nil>,
  @reflection_scope=  Photo Load (33.1ms)  SELECT `photos`.* FROM `photos` /*application:Myapp*/
   [#<Photo:0x0000ffff7d8919a8
     id: 1,
     name: "美しい夕日",
     url: "https://example.com/photos/1.jpg",
     description: "美しい夕日の説明文です。",
     created_at: Sun, 16 Feb 2025 13:10:56.948130000 UTC +00:00,
     updated_at: Sun, 16 Feb 2025 13:10:56.948130000 UTC +00:00,
     category: "selfie",
     user_id: 5>,
     ... 以下略 ...

preload_associationメソッドにより、渡されたすべての records に対して、一括で指定されたアソシエーション(今回の例では :photos)を読み込みます。

これにより、各レコードの関連データを個別に問い合わせるのではなく、1回のクエリでデータを一括取得し、N+1問題を回避することができます。

今回の例で、recordsに格納されているのは[User.first]ですが、recordsに10件のUserモデルのインスタンスが格納されている場合でも、1回のクエリで関連する全ての写真データを一括取得することができます。(preload_associationを実行しない場合、次の行のrecords.each〜で10回のクエリが実行されることになります。)

それでは、次の行のrecords.each〜を見ていきます。

records.each { |record| fulfill(record, read_association(record)) }

read_association(record)は、下記の通りとなっています。
https://github.com/Shopify/graphql-batch/blob/main/examples/association_loader.rb#L42-L44

このメソッドは、各レコードから、すでにpreload_associationによって読み込まれたアソシエーションを取得します。

今回の例だと、User.first.public_send(:photos)のようになり、User オブジェクトの関連データ(Photoの配列)を返します。

こうして、loadメソッドで作成されたすべてのPromiseインスタンスがfulfilled状態となり値が確定します。

まとめ

最後に、ここまで確認してきた内容をまとめてみます。

GraphQL::Batch::Loader.for がやってること

  • AssociationLoader.for を実行すると、GraphQL::Batch::Loader.for が呼び出される
  • GraphQL::Batch::Loader.for は、GraphQL::Batch::Executor.current を呼び出して、現在のExecutorインスタンスを取得する
  • GraphQL::Batch::Executor#loader は、与えられたkeyに対して既に作成済みのローダーがあるかどうかを確認し、なければ新しいローダーのインスタンスを生成する

AssociationLoader#load がやってること

  • AssociationLoader.for(model, association_name).load(object) を実行すると、GraphQL::Batch::Loader#load が呼び出される
  • GraphQL::Batch::Loader#load は、指定されたcache_key(key)に対応するPromiseインスタンスがあればそれを返し、なければqueuekeyを追加した上で新しいPromiseインスタンスを作成して返す
  • AssociationLoader#wait を通じて、GraphQL::Batch::Loader#resolve が実行され、AssociationLoader#perform が実行される
  • AssociationLoader#perform は、preload_association メソッドを実行し、渡されたすべての records に対して一括で指定されたアソシエーションを読み込む
  • records.each { |record| fulfill(record, read_association(record)) } により、各レコードから、すでにpreload_associationによって読み込まれたアソシエーションを取得する

graphql-batchのAssociationLoaderが何をしているのか、コードリーディングをすることで、全容理解までは至らなくとも、以前よりは理解を深めることができました。

今後、examplesにある他のLoaderを利用したり、カスタムLoaderを作成する際に今回学んだ内容を活かせたらなと思います!

理解できた部分がある一方で、下記のように新しく「わからないこと」も出てきました...

これらについては、今回スルーしてしまったので、別で機会を見つけて調べてみたいと思います!

最後まで読んでいただき、ありがとうございました🙇‍♂️

GitHubで編集を提案
合同会社春秋テックブログ

Discussion