rmosolgo / graphql-ruby

Ruby implementation of GraphQL

Home Page:http://graphql-ruby.org

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Schema#multiplex causes context.namespace(:interpreter)[:current_path] to be nil inside `then` block

DmitryTsepelev opened this issue · comments

Describe the bug

I found a case when context.namespace(:interpreter)[:current_path] is nil (to be precise, most of the keys inside the :interpreter hash are missing). The problem appears when Schema#multiplex is called and I try to get the current path inside .then block in promise, i.e.:

class Tweet < GraphQL::Schema::Object
    field :content, String, null: false
    field :author, Types::User, null: false

    def author
      puts "Tweet:author current_path=#{context.namespace(:interpreter)[:current_path]}"

      Batch::RecordLoader.for(::User).load(object.author_id).then do |author|
        puts "Tweet:author after batch current_path=#{context.namespace(:interpreter)[:current_path]}"
        author
      end
    end
  end

However, it works fine when Schema#execute is used.

Not sure if I should go to the graphql-batch repo with this issue.

Versions

graphql version: latest
graphql-batch version: latest

Steps to reproduce

A whole example:

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

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 "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"
end

class User < ActiveRecord::Base
  has_many :tweets, foreign_key: :author_id
end

class Tweet < ActiveRecord::Base
  belongs_to :author, class_name: "User"
end

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

module Types
  class User < GraphQL::Schema::Object
    field :nickname, String, null: false
  end

  class Tweet < GraphQL::Schema::Object
    field :content, String, null: false
    field :author, Types::User, null: false

    def author
      puts "Tweet:author current_path=#{context.namespace(:interpreter)[:current_path]}"

      Batch::RecordLoader.for(::User).load(object.author_id).then do |author|
        puts "Tweet:author after batch current_path=#{context.namespace(:interpreter)[:current_path]}"
        author
      end
    end
  end
end

class FeedResolver < GraphQL::Schema::Resolver
  type [Types::Tweet], null: false

  def resolve(limit: nil, offset: nil)
    Tweet.limit(limit).offset(offset)
  end
end

module Types
  class Query < GraphQL::Schema::Object
    field :feed, [Types::Tweet], null: false, resolver: FeedResolver do
      argument :limit, Integer, required: false
      argument :offset, Integer, required: false
    end
  end
end

class GraphqlSchema < GraphQL::Schema
  query Types::Query
  use GraphQL::Batch
end

Tweet.delete_all
User.delete_all

john = User.create(nickname: "John")
max = User.create(nickname: "Max")

john.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))
john.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))

puts "\nSchema#multiplex\n"

query1 = <<~GQL
  query {
    feed(offset: 0, limit: 2) {
      content
      author {
        nickname
      }
    }
  }
GQL

query2 = <<~GQL
  query {
    feed(offset: 2, limit: 2) {
      content
      author {
        nickname
      }
    }
  }
GQL

puts GraphqlSchema.multiplex([{ query: query1 }, { query: query2 }]).map(&:to_h).inspect

puts "\nSchema#execute\n"

puts GraphqlSchema.execute(query1).to_h.inspect

Expected behavior

I want to be able to get the current path inside the promise 🙂

Actual behavior

Current path is available only inside first promise block. Here is what I see in the console:

Schema#multiplex
Tweet:author current_path=["feed", 0, "author"]
Tweet:author current_path=["feed", 1, "author"]
Tweet:author current_path=["feed", 0, "author"]
Tweet:author current_path=["feed", 1, "author"]
Tweet:author after batch current_path=["feed", 0, "author"]
Tweet:author after batch current_path=
Tweet:author after batch current_path=["feed", 0, "author"]
Tweet:author after batch current_path=
[{"data"=>{"feed"=>[{"content"=>"Hi!", "author"=>{"nickname"=>"John"}}, {"content"=>"Hello!", "author"=>{"nickname"=>"Max"}}]}}, {"data"=>{"feed"=>[{"content"=>"My second tweet is here", "author"=>{"nickname"=>"John"}}, {"content"=>"The weather is nice", "author"=>{"nickname"=>"Max"}}]}}]

Schema#execute
Tweet:author current_path=["feed", 0, "author"]
Tweet:author current_path=["feed", 1, "author"]
Tweet:author after batch current_path=["feed", 0, "author"]
Tweet:author after batch current_path=["feed", 0, "author"]

Hey, thanks for the details on this issue! Yes, this sounds like a bug to me, and this is the right place to report it.

I would have expected those context values to be populated by this:

set_all_interpreter_context(owner_object, field, arguments, path)

But evidently that's not the case!

(derp, wrong button.)

I've tried to add logging right after graphql-ruby/lib/graphql/execution/interpreter/runtime.rb:489 and figured out, that it's called once before resolving the lazy object (and 3 more times after):

Schema#multiplex
Tweet:author current_path=["feed", 0, "author"]
Tweet:author current_path=["feed", 1, "author"]
Tweet:author current_path=["feed", 0, "author"]
Tweet:author current_path=["feed", 1, "author"]
set_all_interpreter_context(owner_object, #<GraphQL::Schema::Field:0x00007fcf0af871f0>, {}, ["feed", 0, "author"])
Tweet:author after batch current_path=["feed", 0, "author"]
Tweet:author after batch current_path=
Tweet:author after batch current_path=["feed", 0, "author"]
Tweet:author after batch current_path=
set_all_interpreter_context(owner_object, #<GraphQL::Schema::Field:0x00007fcf0af871f0>, {}, ["feed", 1, "author"])
set_all_interpreter_context(owner_object, #<GraphQL::Schema::Field:0x00007fcf0af871f0>, {}, ["feed", 0, "author"])
set_all_interpreter_context(owner_object, #<GraphQL::Schema::Field:0x00007fcf0af871f0>, {}, ["feed", 1, "author"])
[{"data"=>{"feed"=>[{"content"=>"Hi!", "author"=>{"nickname"=>"John"}}, {"content"=>"Hello!", "author"=>{"nickname"=>"Max"}}]}}, {"data"=>{"feed"=>[{"content"=>"My second tweet is here", "author"=>{"nickname"=>"John"}}, {"content"=>"The weather is nice", "author"=>{"nickname"=>"Max"}}]}}]

Ohhh, interesting. I guess it's because the promise created by .then is actually executed at graphql-batch's discretion. It probably runs that promise while running the one for the previous field, which would be before the Execution::Lazy which contains the promise is executed. So, GraphQL-Ruby has no chance of updating context in that case -- control flow is in the hands of graphql-batch instead.

As a work-around, you might have to capture context[:current_path] outside the promise and somehow pass it in (using lexical scope) to the code inside it.

Otherwise, I'm not sure how GraphQL-Ruby could inject runtime info (for other fields) into GraphQL-batch's promise resolution 😖

Although practically irrelevant, it looks like GraphQL::Dataloader "just works" in this regard. Thanks for your excellent reproduction of this issue -- when I modified it to use GraphQL::Dataloader, it outputted the expected:

Diff of replication example

 git diff --no-index original_issue.rb modified_issue.rb
diff --git a/original_issue.rb b/modified_issue.rb
index 9cf7ec80a..36f46bbde 100644
--- a/original_issue.rb
+++ b/modified_issue.rb
@@ -54,14 +54,15 @@ class Tweet < ActiveRecord::Base
 end

 module Batch
-  class RecordLoader < GraphQL::Batch::Loader
-    def initialize(model)
-      @model = model
+  class RecordSource < GraphQL::Dataloader::Source
+    def initialize(model_class)
+      @model_class = model_class
     end

-    def perform(ids)
-      @model.where(id: ids).each { |record| fulfill(record.id, record) }
-      ids.each { |id| fulfill(id, nil) unless fulfilled?(id) }
+    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
 end
@@ -78,10 +79,9 @@ module Types
     def author
       puts "Tweet:author current_path=#{context.namespace(:interpreter)[:current_path]}"

-      Batch::RecordLoader.for(::User).load(object.author_id).then do |author|
-        puts "Tweet:author after batch current_path=#{context.namespace(:interpreter)[:current_path]}"
-        author
-      end
+      author = dataloader.with(Batch::RecordSource, ::User).load(object.author_id)
+      puts "Tweet:author after batch current_path=#{context.namespace(:interpreter)[:current_path]}"
+      author
     end
   end
 end
@@ -105,7 +105,7 @@ end

 class GraphqlSchema < GraphQL::Schema
   query Types::Query
-  use GraphQL::Batch
+  use GraphQL::Dataloader
 end

 Tweet.delete_all

Yeah, I don't see any clear way to update the context inside the promise block. Closing the issue, thank you for the help!