How to run examples:
- Run $ createdb nplusonedb to create DB
- Run specs $ rspec demo.rb
How to run examples:
| begin | |
| require "bundler/inline" | |
| rescue LoadError => e | |
| $stderr.puts "Bundler version 1.10 or later is required. Please update your Bundler" | |
| raise e | |
| end | |
| gemfile(true) do | |
| source "https://rubygems.org" | |
| gem "rails", "6.0.3" | |
| gem "pg" | |
| gem "graphql", "~> 1.12" | |
| gem "rspec-rails", "~> 4.0.1" | |
| gem "db-query-matchers" | |
| gem "ar_lazy_preload" | |
| gem "graphql-batch" | |
| end | |
| # app initialization | |
| require "active_record" | |
| class App < Rails::Application | |
| config.logger = Logger.new('/dev/null') | |
| end | |
| App.initialize! | |
| ActiveRecord::Base.establish_connection(adapter: "postgresql", database: "nplusonedb") | |
| ActiveRecord::Schema.define do | |
| enable_extension "plpgsql" | |
| create_table "users", force: :cascade do |t| | |
| t.string "nickname", null: false | |
| t.datetime "created_at", precision: 6, null: false | |
| t.datetime "updated_at", precision: 6, null: false | |
| end | |
| create_table "user_connections", force: :cascade do |t| | |
| t.bigint "user_id", null: false | |
| t.bigint "follower_id", null: false | |
| t.datetime "created_at", precision: 6, null: false | |
| t.datetime "updated_at", precision: 6, null: false | |
| end | |
| create_table "tweets", force: :cascade do |t| | |
| t.text "content", null: false | |
| t.bigint "author_id", null: false | |
| t.datetime "created_at", precision: 6, null: false | |
| t.datetime "updated_at", precision: 6, null: false | |
| end | |
| add_foreign_key "tweets", "users", column: "author_id" | |
| add_foreign_key "user_connections", "users", column: "user_id" | |
| add_foreign_key "user_connections", "users", column: "follower_id" | |
| end | |
| # models | |
| class UserConnection < ActiveRecord::Base | |
| belongs_to :user | |
| belongs_to :follower, class_name: "User" | |
| end | |
| class User < ActiveRecord::Base | |
| has_many :tweets, foreign_key: :author_id | |
| has_many :followers_connections, class_name: "UserConnection", foreign_key: :user_id | |
| has_many :followers, through: :followers_connections, source: :follower, class_name: "User" | |
| has_many :followed_connections, class_name: "UserConnection", foreign_key: :follower_id | |
| has_many :followed_users, through: :followed_connections, source: :user, class_name: "User" | |
| end | |
| class Tweet < ActiveRecord::Base | |
| belongs_to :author, class_name: "User" | |
| end | |
| # Batch loader | |
| module Batch | |
| class RecordLoader < GraphQL::Batch::Loader | |
| def initialize(model) | |
| @model = model | |
| end | |
| def perform(ids) | |
| @model.where(id: ids).each { |record| fulfill(record.id, record) } | |
| ids.each { |id| fulfill(id, nil) unless fulfilled?(id) } | |
| end | |
| end | |
| end | |
| # Dataloader | |
| module Sources | |
| class ActiveRecord < GraphQL::Dataloader::Source | |
| def initialize(model_class) | |
| @model_class = model_class | |
| end | |
| def fetch(ids) | |
| records = @model_class.where(id: ids).index_by(&:id) | |
| records.slice(*ids).values | |
| end | |
| end | |
| end | |
| # Feed query | |
| module FeedBuilder | |
| module_function | |
| def for(user) | |
| Tweet.where(author: user.followed_users) | |
| .order(created_at: :desc) | |
| .limit(10) | |
| end | |
| end | |
| # GraphQL types part 1 | |
| module WithCurrentUser | |
| def current_user | |
| context[:current_user] | |
| end | |
| end | |
| class BaseObject < GraphQL::Schema::Object | |
| include WithCurrentUser | |
| end | |
| class BaseResolver < GraphQL::Schema::Resolver | |
| include WithCurrentUser | |
| end | |
| module Types | |
| class User < BaseObject | |
| field :nickname, String, null: false | |
| field :is_followed, Boolean, null: false, resolver_method: :followed? | |
| field :followers, [User], "cannot be fetched for multiple users at once", null: false do | |
| argument :limit, Integer, required: true, default_value: 2 | |
| argument :cursor, Integer, required: false | |
| end | |
| def followed? | |
| Resolvers::LazyFollowedResolver.new(context, object.id) | |
| end | |
| def followers(limit:, cursor: nil) | |
| scope = object.followers.order(id: :desc).limit(limit) | |
| scope = scope.where("id < cursor", cursor) if cursor | |
| scope | |
| end | |
| end | |
| class Tweet < BaseObject | |
| field :content, String, null: false | |
| field :author, Types::User, null: false | |
| field :author_lazy, Types::User, null: false | |
| field :author_batch, Types::User, null: false | |
| field :author_dataloader, Types::User, null: false | |
| def author_lazy | |
| Resolvers::LazyUserResolver.new(context, object.author_id) | |
| end | |
| def author_batch | |
| Batch::RecordLoader.for(::User).load(object.author_id) | |
| end | |
| def author_dataloader | |
| dataloader.with(Sources::ActiveRecord, ::User).load(object.author_id) | |
| end | |
| end | |
| end | |
| # GraphQL resolvers | |
| module Resolvers | |
| class BaseFeedResolver < BaseResolver | |
| type [Types::Tweet], null: false | |
| def resolve | |
| raise NotImplementedError | |
| end | |
| end | |
| class FeedResolver < BaseFeedResolver | |
| def resolve | |
| FeedBuilder.for(current_user) | |
| end | |
| end | |
| class FeedResolverPreload < BaseFeedResolver | |
| def resolve | |
| FeedBuilder.for(current_user).includes(:author) | |
| end | |
| end | |
| class FeedResolverLookahead < BaseFeedResolver | |
| extras [:lookahead] | |
| def resolve(lookahead:) | |
| FeedBuilder.for(current_user) | |
| .merge(relation_with_includes(lookahead)) | |
| end | |
| private | |
| def relation_with_includes(lookahead) | |
| return Tweet.all unless lookahead.selects?(:author) | |
| Tweet.includes(:author) | |
| end | |
| end | |
| class FeedResolverLazyPreload < BaseFeedResolver | |
| def resolve | |
| FeedBuilder.for(current_user).lazy_preload(:author) | |
| end | |
| end | |
| class LazyUserResolver | |
| def initialize(context, user_id) | |
| @user_id = user_id | |
| @lazy_state = context[:lazy_user_resolver] ||= { | |
| user_ids: Set.new, | |
| users_cache: nil, | |
| } | |
| @lazy_state[:user_ids] << user_id | |
| end | |
| def user | |
| users_cache[@user_id] | |
| end | |
| private | |
| def users_cache | |
| @lazy_state[:users_cache] ||= | |
| begin | |
| user_ids = @lazy_state[:user_ids].to_a | |
| @lazy_state[:user_ids].clear | |
| User.where(id: user_ids).index_by(&:id) | |
| end | |
| end | |
| end | |
| class LazyFollowedResolver | |
| def initialize(context, user_id) | |
| @user_id = user_id | |
| @context = context | |
| @lazy_state = context[:lazy_followed_resolver] ||= { | |
| user_ids: Set.new, | |
| users_cache: nil, | |
| } | |
| @lazy_state[:user_ids] << user_id | |
| end | |
| def is_followed? | |
| return false unless current_user | |
| users_cache[@user_id] | |
| end | |
| private | |
| def current_user | |
| @context[:current_user] | |
| end | |
| def users_cache | |
| @lazy_state[:users_cache] ||= | |
| begin | |
| user_ids = @lazy_state[:user_ids].to_a | |
| @lazy_state[:user_ids].clear | |
| User.where(id: user_ids.to_a) | |
| .pluck(Arel.sql("id, EXISTS(#{followed_query.to_sql}) AS is_followed")) | |
| .to_h | |
| end | |
| end | |
| def followed_query | |
| Arel::Table.new(:user_connections).then do |table| | |
| table.where(table[:follower_id].eq(current_user.id)) | |
| .where(table[:user_id].eq(User.arel_table[:id])) | |
| .project("1") | |
| end | |
| end | |
| end | |
| end | |
| # More GraphQL types | |
| module Types | |
| class Viewer < BaseObject | |
| field :feed, resolver: Resolvers::FeedResolver | |
| field :feed_with_preload, resolver: Resolvers::FeedResolverPreload | |
| field :feed_with_lookahead, resolver: Resolvers::FeedResolverLookahead | |
| field :feed_with_lazy_preload, resolver: Resolvers::FeedResolverLazyPreload | |
| end | |
| class Query < BaseObject | |
| field :viewer, Types::Viewer, null: true, resolver_method: :current_user | |
| field :users, [User], null: false, extras: [:lookahead] | |
| field :user, User, null: true do | |
| argument :user_id, ID, required: true | |
| end | |
| def users(lookahead:) | |
| if lookahead.selects?(:followers) | |
| raise GraphQL::ExecutionError, "followers can be accessed in singular association only" | |
| end | |
| ::User.all | |
| end | |
| def user(user_id:) | |
| ::User.find(user_id) | |
| rescue ActiveRecord::RecordNotFound | |
| nil | |
| end | |
| end | |
| end | |
| class GraphqlSchema < GraphQL::Schema | |
| lazy_resolve(Resolvers::LazyUserResolver, :user) | |
| lazy_resolve(Resolvers::LazyFollowedResolver, :is_followed?) | |
| query Types::Query | |
| use GraphQL::Batch | |
| end | |
| class GraphqlDataloaderSchema < GraphQL::Schema | |
| query Types::Query | |
| use GraphQL::Dataloader | |
| end | |
| # Specs | |
| require "action_controller" | |
| require 'rspec/rails' | |
| RSpec.configure do |config| | |
| config.use_transactional_fixtures = true | |
| end | |
| RSpec.describe 'feed' do | |
| let(:john) { User.create(nickname: "John") } | |
| let(:jane) { User.create(nickname: "Jane") } | |
| let(:max) { User.create(nickname: "Max") } | |
| let(:query) do | |
| <<~GQL | |
| query { | |
| viewer { | |
| feed { | |
| content | |
| author { | |
| nickname | |
| } | |
| } | |
| } | |
| } | |
| GQL | |
| end | |
| subject do | |
| GraphqlSchema.execute(query, context: { current_user: john }).to_h | |
| end | |
| before do | |
| jane.followers << john | |
| max.followers << john | |
| jane.tweets.create(content: "Hi!", created_at: Time.new(2020, 6, 12, 10)) | |
| max.tweets.create(content: "Hello!", created_at: Time.new(2020, 6, 13, 10)) | |
| jane.tweets.create(content: "My second tweet is here", created_at: Time.new(2020, 6, 15, 10)) | |
| max.tweets.create(content: "The weather is nice", created_at: Time.new(2020, 6, 17, 10)) | |
| end | |
| shared_examples "feed loading" do | |
| it "returns feed data" do | |
| expect(subject["data"]).to eq( | |
| "viewer" => { | |
| "feed" => [ | |
| { "content" => "The weather is nice", "author" => { "nickname" => "Max" } }, | |
| { "content" => "My second tweet is here", "author" => { "nickname" => "Jane" } }, | |
| { "content" => "Hello!", "author" => { "nickname" => "Max" } }, | |
| { "content" => "Hi!", "author" => { "nickname" => "Jane" } } | |
| ] | |
| } | |
| ) | |
| end | |
| end | |
| context "without N+1" do | |
| include_examples "feed loading" | |
| it "performs 5 queries" do | |
| expect { subject }.to make_database_queries(count: 5) | |
| end | |
| end | |
| context "with preloading" do | |
| let(:query) do | |
| <<~GQL | |
| query { | |
| viewer { | |
| feed: feedWithPreload { | |
| content | |
| author { | |
| nickname | |
| } | |
| } | |
| } | |
| } | |
| GQL | |
| end | |
| include_examples "feed loading" | |
| it "performs 2 queries" do | |
| expect { subject }.to make_database_queries(count: 2) | |
| end | |
| context "when author is not requested" do | |
| let(:query) do | |
| <<~GQL | |
| query { | |
| viewer { | |
| feed: feedWithPreload { | |
| content | |
| } | |
| } | |
| } | |
| GQL | |
| end | |
| it "performs 2 queries" do | |
| expect { subject }.to make_database_queries(count: 2) | |
| end | |
| end | |
| end | |
| context "with lookahead" do | |
| let(:query) do | |
| <<~GQL | |
| query { | |
| viewer { | |
| feed: feedWithLookahead { | |
| content | |
| author { | |
| nickname | |
| } | |
| } | |
| } | |
| } | |
| GQL | |
| end | |
| include_examples "feed loading" | |
| it "performs 2 queries" do | |
| expect { subject }.to make_database_queries(count: 2) | |
| end | |
| context "when author is not requested" do | |
| let(:query) do | |
| <<~GQL | |
| query { | |
| viewer { | |
| feed: feedWithLookahead { | |
| content | |
| } | |
| } | |
| } | |
| GQL | |
| end | |
| it "performs 1 query" do | |
| expect { subject }.to make_database_queries(count: 1) | |
| end | |
| end | |
| end | |
| context "with lazy preloading" do | |
| let(:query) do | |
| <<~GQL | |
| query { | |
| viewer { | |
| feed: feedWithLazyPreload { | |
| content | |
| author { | |
| nickname | |
| } | |
| } | |
| } | |
| } | |
| GQL | |
| end | |
| include_examples "feed loading" | |
| it "performs 2 queries" do | |
| expect { subject }.to make_database_queries(count: 2) | |
| end | |
| context "when author is not requested" do | |
| let(:query) do | |
| <<~GQL | |
| query { | |
| viewer { | |
| feed: feedWithLazyPreload { | |
| content | |
| } | |
| } | |
| } | |
| GQL | |
| end | |
| it "performs 1 query" do | |
| expect { subject }.to make_database_queries(count: 1) | |
| end | |
| end | |
| end | |
| context "with lazy resolver" do | |
| let(:query) do | |
| <<~GQL | |
| query { | |
| viewer { | |
| feed { | |
| content | |
| author: authorLazy { | |
| nickname | |
| } | |
| } | |
| } | |
| } | |
| GQL | |
| end | |
| include_examples "feed loading" | |
| it "performs 2 queries" do | |
| expect { subject }.to make_database_queries(count: 2) | |
| end | |
| end | |
| context "with batch loading" do | |
| let(:query) do | |
| <<~GQL | |
| query { | |
| viewer { | |
| feed { | |
| content | |
| author: authorBatch { | |
| nickname | |
| } | |
| } | |
| } | |
| } | |
| GQL | |
| end | |
| include_examples "feed loading" | |
| it "performs 2 queries" do | |
| expect { subject }.to make_database_queries(count: 2) | |
| end | |
| end | |
| context "with dataloader" do | |
| subject do | |
| GraphqlDataloaderSchema.execute(query, context: { current_user: john }).to_h | |
| end | |
| let(:query) do | |
| <<~GQL | |
| query { | |
| viewer { | |
| feed { | |
| content | |
| author: authorDataloader { | |
| nickname | |
| } | |
| } | |
| } | |
| } | |
| GQL | |
| end | |
| include_examples "feed loading" | |
| it "performs 2 queries" do | |
| expect { subject }.to make_database_queries(count: 2) | |
| end | |
| end | |
| end | |
| RSpec.describe 'followed' do | |
| let(:john) { User.create(nickname: "John") } | |
| let(:jane) { User.create(nickname: "Jane") } | |
| let(:max) { User.create(nickname: "Max") } | |
| let(:query) do | |
| <<~GQL | |
| query { | |
| viewer { | |
| feed { | |
| content | |
| author: authorLazy { | |
| nickname | |
| isFollowed | |
| } | |
| } | |
| } | |
| } | |
| GQL | |
| end | |
| subject do | |
| GraphqlSchema.execute(query, context: { current_user: john }).to_h | |
| end | |
| before do | |
| jane.followers << john | |
| max.followers << john | |
| jane.tweets.create(content: "Hi!", created_at: Time.new(2020, 6, 12, 10)) | |
| max.tweets.create(content: "Hello!", created_at: Time.new(2020, 6, 13, 10)) | |
| end | |
| it "returns followed data" do | |
| expect(subject["data"]).to eq( | |
| "viewer" => { | |
| "feed" => [ | |
| { "content" => "Hello!", "author" => { "nickname" => "Max", "isFollowed" => true } }, | |
| { "content" => "Hi!", "author" => { "nickname" => "Jane", "isFollowed" => true } } | |
| ] | |
| } | |
| ) | |
| end | |
| it "performs 3 queries" do | |
| expect { subject }.to make_database_queries(count: 3) | |
| end | |
| end | |
| RSpec.describe 'followers' do | |
| let(:john) { User.create(nickname: "John") } | |
| let(:jane) { User.create(nickname: "Jane") } | |
| let(:max) { User.create(nickname: "Max") } | |
| subject do | |
| GraphqlSchema.execute(query, context: { current_user: john }, variables: { userId: john.id }).to_h | |
| end | |
| before do | |
| john.followers << jane | |
| john.followers << max | |
| jane.followers << john | |
| jane.followers << max | |
| end | |
| context "when followers of one user are requested" do | |
| let(:query) do | |
| <<~GQL | |
| query GetUser($userId: ID!) { | |
| user(userId: $userId) { | |
| followers { | |
| nickname | |
| } | |
| } | |
| } | |
| GQL | |
| end | |
| it "returns followed data" do | |
| expect(subject["data"]).to eq( | |
| "user" => { | |
| "followers" => [ | |
| { "nickname" => "Max" }, | |
| { "nickname" => "Jane" } | |
| ] | |
| } | |
| ) | |
| end | |
| it "performs 2 queries" do | |
| expect { subject }.to make_database_queries(count: 2) | |
| end | |
| end | |
| context "when users with followers are requested" do | |
| let(:query) do | |
| <<~GQL | |
| query { | |
| users { | |
| nickname | |
| followers { | |
| nickname | |
| } | |
| } | |
| } | |
| GQL | |
| end | |
| it "returns error" do | |
| expect(subject["errors"].map { |error| error["message"] }).to \ | |
| include("followers can be accessed in singular association only") | |
| end | |
| end | |
| end |
There is a little typo on the line 277 in the comment for the code:
Mpre GraphQL types
Oh, my bad, thanks @georgiybykov!
Thanks a lot for your article! It was very interesting and useful to read.