確認環境
$ 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'