GraphQL Rubyでgraphql-batchを使ってみる (Rails)

December 24, 2022

確認環境

$ bundle exec ruby --version
ruby 2.7.5p203 (2021-11-24 revision f69aeb8314) [x86_64-darwin19]
$ bundle exec rails --version
Rails 6.0.4.6
$ bundle info graphql
  * graphql (2.0.13)
	Summary: A GraphQL language and runtime for Ruby
	Homepage: https://github.com/rmosolgo/graphql-ruby
	Path: /Users/xxxxx/.rbenv/versions/2.7.5/lib/ruby/gems/2.7.0/gems/graphql-2.0.13

データ準備

テーブル定義確認

$ rails db
sqlite> .schema users
CREATE TABLE IF NOT EXISTS "users" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar DEFAULT '' NOT NULL, "encrypted_password" varchar DEFAULT '' NOT NULL, "reset_password_token" varchar, "reset_password_sent_at" datetime, "remember_created_at" datetime, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL);
CREATE UNIQUE INDEX "index_users_on_email" ON "users" ("email");
CREATE UNIQUE INDEX "index_users_on_reset_password_token" ON "users" ("reset_password_token");
sqlite> .schema posts
CREATE TABLE IF NOT EXISTS "posts" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "title" varchar, "description" text, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, "status" integer, "user_id" integer);

検証コードを実装

N + 1 が発生する実装

app/models/post.rb

class Post < ApplicationRecord
  belongs_to :user
end

app/models/user.rb

class User < ApplicationRecord
end

app/graphql/types/query_type.rb

module Types
  class QueryType < Types::BaseObject
    field :posts, [Types::PostType], null: false
    def posts
      Post.all.limit(5)
    end
  end
end

app/graphql/types/user_type.rb

# frozen_string_literal: true

module Types
  class UserType < Types::BaseObject
    global_id_field :id

    field :email, String
  end
end

app/graphql/types/post_type.rb

# frozen_string_literal: true

module Types
  class PostType < Types::BaseObject
    global_id_field :id

    field :title, String
    field :status, Types::PostStatusEnum
    field :created_at, GraphQL::Types::ISO8601DateTime, null: false
    field :updated_at, GraphQL::Types::ISO8601DateTime, null: false

    field :user, Types::UserType, null: false
  end
end

N + 1 の発生を確認

query {
  posts {
    title
    user {
      id
    }
  }
}

実行時のアプリケーションログ

Creating scope :open. Overwriting existing method Post.open.
  Post Load (0.2ms)  SELECT "posts".* FROM "posts" LIMIT ?  [["LIMIT", 5]]
  ↳ app/controllers/graphql_controller.rb:15:in `execute'
  User Load (0.2ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  ↳ app/controllers/graphql_controller.rb:15:in `execute'
  CACHE User Load (0.0ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  ↳ app/controllers/graphql_controller.rb:15:in `execute'
  CACHE User Load (0.0ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  ↳ app/controllers/graphql_controller.rb:15:in `execute'
  CACHE User Load (0.0ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  ↳ app/controllers/graphql_controller.rb:15:in `execute'
  CACHE User Load (0.0ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  ↳ app/controllers/graphql_controller.rb:15:in `execute'
Completed 200 OK in 126ms (Views: 0.3ms | ActiveRecord: 2.2ms | Allocations: 23453)

graphql-batch を導入する

Gemfile

gem 'graphql-batch'
$ bundle install

インストールされたことを確認

$ bundle info graphql-batch
  * graphql-batch (0.5.1)
        Summary: A query batching executor for the graphql gem
        Homepage: https://github.com/Shopify/graphql-batch
        Path: /Users/xxxx/.rbenv/versions/2.7.5/lib/ruby/gems/2.7.0/gems/graphql-batch-0.5.1
class Sample6Schema < GraphQL::Schema
  mutation(Types::MutationType)
  query(Types::QueryType)

  use GraphQL::Batch
end

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
      return if @model.reflect_on_association(@association_name)

      raise ArgumentError, "No association #{@association_name} on #{@model}"
    end

    def preload_association(records)
      ::ActiveRecord::Associations::Preloader.new.preload(records, @association_name)
    end

    def read_association(record)
      record.public_send(@association_name)
    end

    def association_loaded?(record)
      record.association(@association_name).loaded?
    end
  end
end

app/graphql/types/post_type.rb

user メソッドを追加します。

# frozen_string_literal: true

module Types
  class PostType < Types::BaseObject
    global_id_field :id

    field :title, String
    field :status, Types::PostStatusEnum
    field :created_at, GraphQL::Types::ISO8601DateTime, null: false
    field :updated_at, GraphQL::Types::ISO8601DateTime, null: false

    field :user, Types::UserType, null: false

    def user
      Loaders::AssociationLoader.for(Post, :user).load(object)
    end
  end
end

N + 1 が解消されたことを確認

query {
  posts {
    title
    user {
      id
    }
  }
}

実行時のアプリケーションログ

Creating scope :open. Overwriting existing method Post.open.
  Post Load (0.4ms)  SELECT "posts".* FROM "posts" LIMIT ?  [["LIMIT", 5]]
  ↳ app/controllers/graphql_controller.rb:15:in `execute'
  User Load (0.6ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ?  [["id", 1]]
  ↳ app/graphql/loaders/association_loader.rb:41:in `preload_association'

参考


SHARE

Profile picture

Written by tamesuu