Code Monkey home page Code Monkey logo

graphql-batch's Introduction

GraphQL::Batch

Build Status Gem Version

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

Installation

Add this line to your application's Gemfile:

gem 'graphql-batch'

And then execute:

$ bundle

Or install it yourself as:

$ gem install graphql-batch

Usage

Basic Usage

Schema Configuration

Require the library

require 'graphql/batch'

Define a custom loader, which is initialized with arguments that are used for grouping and a perform method for performing the batch load.

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

Use GraphQL::Batch as a plugin in your schema after specifying the mutation so that GraphQL::Batch can extend the mutation fields to clear the cache after they are resolved.

class MySchema < GraphQL::Schema
  query MyQueryType
  mutation MyMutationType

  use GraphQL::Batch
end

Field Usage

The loader class can be used from the resolver for a graphql field by calling .for with the grouping arguments to get a loader instance, then call .load on that instance with the key to load.

field :product, Types::Product, null: true do
  argument :id, ID, required: true
end

def product(id:)
  RecordLoader.for(Product).load(id)
end

The loader also supports batch loading an array of records instead of just a single record, via load_many. For example:

field :products, [Types::Product, null: true], null: false do
  argument :ids, [ID], required: true
end

def products(ids:)
  RecordLoader.for(Product).load_many(ids)
end

Although this library doesn't have a dependency on active record, the examples directory has record and association loaders for active record which handles edge cases like type casting ids and overriding GraphQL::Batch::Loader#cache_key to load associations on records with the same id.

Promises

GraphQL::Batch::Loader#load returns a Promise using the promise.rb gem to provide a promise based API, so you can transform the query results using .then

def product_title(id:)
  RecordLoader.for(Product).load(id).then do |product|
    product.title
  end
end

You may also need to do another query that depends on the first one to get the result, in which case the query block can return another query.

def product_image(id:)
  RecordLoader.for(Product).load(id).then do |product|
    RecordLoader.for(Image).load(product.image_id)
  end
end

If the second query doesn't depend on the first one, then you can use Promise.all, which allows each query in the group to be batched with other queries.

def all_collections
  Promise.all([
    CountLoader.for(Shop, :smart_collections).load(context.shop_id),
    CountLoader.for(Shop, :custom_collections).load(context.shop_id),
  ]).then(&:sum)
end

.then can optionally take two lambda arguments, the first of which is equivalent to passing a block to .then, and the second one handles exceptions. This can be used to provide a fallback

def product(id:)
  # Try the cache first ...
  CacheLoader.for(Product).load(id).then(nil, lambda do |exc|
    # But if there's a connection error, go to the underlying database
    raise exc unless exc.is_a?(Redis::BaseConnectionError)
    logger.warn err.message
    RecordLoader.for(Product).load(id)
  end)
end

Priming the Cache

You can prime the loader cache with a specific value, which can be useful in certain situations.

def liked_products
  liked_products = Product.where(liked: true).load
  liked_products.each do |product|
    RecordLoader.for(Product).prime(product.id, product)
  end
end

Priming will add key/value to the loader cache only if it didn't exist before.

Unit Testing

Your loaders can be tested outside of a GraphQL query by doing the batch loads in a block passed to GraphQL::Batch.batch. That method will set up thread-local state to store the loaders, batch load any promise returned from the block then clear the thread-local state to avoid leaking state between tests.

def test_single_query
  product = products(:snowboard)
  title = GraphQL::Batch.batch do
    RecordLoader.for(Product).load(product.id).then(&:title)
  end
  assert_equal product.title, title
end

Development

After checking out the repo, run bin/setup to install dependencies. Then, run rake test to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.

Contributing

See our contributing guidelines for more information.

License

The gem is available as open source under the terms of the MIT License.

graphql-batch's People

Contributors

amomchilov avatar byroot avatar chipairon avatar chrisbutcher avatar cjoudrey avatar dentarg avatar dipth avatar dylanahsmith avatar eapache avatar george-ma avatar jhoffmcd avatar keoghpe avatar ksylvest avatar leonidkroka avatar letiesperon avatar nicolabba avatar paradite avatar rmosolgo avatar sj26 avatar sjchmiela avatar staugaard avatar swalkinshaw avatar tgwizard avatar theojulienne avatar toneymathews avatar waldyr avatar willisplummer avatar xuorig avatar y-yagi avatar yuchunc avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

graphql-batch's Issues

graphql-ruby 1.9 interpreter support

1.9 offers a new optional interpreter and AST-based analysis which has breaking changes. Field instrumentation won't be supported and graphql-batch uses it for mutations:

def instrument_field(schema, type, field)
return field unless type == schema.mutation
old_resolve_proc = field.resolve_proc
field.redefine do
resolve ->(obj, args, ctx) {
GraphQL::Batch::Executor.current.clear
begin
::Promise.sync(old_resolve_proc.call(obj, args, ctx))
ensure
GraphQL::Batch::Executor.current.clear
end
}
end
end

Ideally we'd support both and conditionally check the gem version. Here's one idea on how to support 1.9 borrowed from @rmosolgo :

def resolve_with_support(**kwargs)
  GraphQL::Batch::Executor.current.clear
  result = super
  context.schema.sync_lazy(result)
ensure
  GraphQL::Batch::Executor.current.clear
end

We'll have to figure out a good way of hooking into resolve_with_support. I'm not sure it's possible without some manual intervention (including a module for example).

Non-nullable fields that raise an ExecutionError cause an uncaught exception

While investigating an issue I noticed that when using graphql-batch, a non-nullable field that raises a GraphQL::ExecutionError causes an uncaught GraphQL::InvalidNullError exception.

I have yet to find the exact commit in graphql-ruby that caused this regression, but it's reproducible with the following diff:

diff --git a/test/batch_test.rb b/test/batch_test.rb
index daf48f0..4234c92 100644
--- a/test/batch_test.rb
+++ b/test/batch_test.rb
@@ -79,6 +79,22 @@ class GraphQL::BatchTest < Minitest::Test
     assert_equal ["Product/123"], queries
   end

+  def test_non_null_that_raises
+    query_string = <<-GRAPHQL
+      {
+        product(id: "1") {
+          id
+          nonNullButRaises {
+            id
+          }
+        }
+      }
+    GRAPHQL
+    result = Schema.execute(query_string)
+  end
+
   def test_batched_association_preload
     query_string = <<-GRAPHQL
       {
diff --git a/test/support/schema.rb b/test/support/schema.rb
index c6eb843..aa97340 100644
--- a/test/support/schema.rb
+++ b/test/support/schema.rb
@@ -55,6 +55,13 @@ ProductType = GraphQL::ObjectType.define do
       Promise.all([query]).then { query.value.size }
     }
   end
+
+  field :nonNullButRaises do
+    type !ImageType
+    resolve -> (_, _, _) {
+      raise GraphQL::ExecutionError, 'Error'
+    }
+  end

At first I thought it was graphql-ruby that didn't handle this case correctly, but then I tried it out and it worked as expected.

I believe in this case you'd expect result to be something along the lines of:

{
  "data": {
    "products": null
  },
  "errors": [
    ... 
  ]
}

@dylanahsmith any idea? If not, I can take a look later this week.

Duplicate read of associated records when using AssociationLoader

Hi everyone!

First, i would like to thank you for your awesome job on the OSS community!

I've probably a bug using graphql-batch. I've duplicate reads of replacements associated records when i use the AssociationLoader class, only after a succeed mutation, not when i use a query. If i disable the batch it works well.

Any idea of what's going bad?

data: {,โ€ฆ}
createMandate: {...}
errors: []
mandate: {โ€ฆ}
[...]
replacements: [{id: "144", flexibleTime: false, endsAt: "2019-04-12T01:00:00Z", priority: false,โ€ฆ},โ€ฆ]
0: {id: "144", flexibleTime: false, endsAt: "2019-04-12T01:00:00Z", priority: false,โ€ฆ}
1: {id: "145", flexibleTime: false, endsAt: "2019-04-13T01:00:00Z", priority: false,โ€ฆ}
2: {id: "144", flexibleTime: false, endsAt: "2019-04-12T01:00:00Z", priority: false,โ€ฆ}
3: {id: "145", flexibleTime: false, endsAt: "2019-04-13T01:00:00Z", priority: false,โ€ฆ}
[...]
success: true
# app/models/mandate.rb
class Mandate < ApplicationRecord
  has_many :replacements, class_name: 'Replacement', autosave: true, dependent: :destroy
  has_many :proposals, through: :replacements, class_name: 'Proposal'

  accepts_nested_attributes_for :replacements, allow_destroy: true
end
# app/graphql/types/mandate.rb
class Types::Mandate < Types::BaseObject
  [..]
  field :replacements, [::Types::Replacement], "Mandate's replacements", null: false
  [..]

  def replacements
    AssociationLoader.for(Mandate, :replacements).load(object)
  end
end
# app/graphql/association_loader.rb
class AssociationLoader < GraphQL::Batch::Loader
  def self.validate(model, association_name)
    new(model, association_name)
    nil
  end

  def initialize(model, association_name)
    @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.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

Add example of a custom non-record-loading loader

In the README, a CountLoader example is named, but it's not particularly detailed:

CountLoader.for(Shop, :smart_collections).load(context.shop_id),
CountLoader.for(Shop, :custom_collections).load(context.shop_id),

A clear example that demonstrates "You can also batch other stuff, not only load records from a DB" could be very helpful for new users. A great example would be something that does a HTTP request to some external API, combining two non-record-from-db-loading use cases.

Batch-loader or preloading with destroy and dependent: :destroy associations N+1 problems.

In Rails there is the model.destroy(id) command to delete (with callbacks) a model. Good.

But when you use dependent: :destroy with has_many or has_one it has fun never ending with N+1 queries!!! :(

Especially if I use polymorhpic associations.

See this:

Started POST "/api/v1" for 172.18.0.1 at 2017-12-28 00:05:38 +0000
Processing by GraphqlController#execute as */*
  Parameters: {"query"=>"mutation deleteProductMutation {\n  deleteProduct(id: 1) {\n    id\n  }\n}\n", "graphql"=>{"query"=>"mutation deleteProductMutation {\n  deleteProduct(id: 1) {\n    id\n  }\n}\n"}}
  User Load (1.0ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
  Product Load (0.4ms)  SELECT  "products".* FROM "products" WHERE "products"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
   (0.3ms)  BEGIN
  Team Load (0.4ms)  SELECT  "teams".* FROM "teams" WHERE "teams"."product_id" = $1 LIMIT $2  [["product_id", 1], ["LIMIT", 1]]
  MotherItem Load (0.5ms)  SELECT "game_items".* FROM "game_items" WHERE "game_items"."team_id" = $1  [["team_id", 1]]
  PlayerProfile Load (1.3ms)  SELECT  "player_profiles".* FROM "player_profiles" WHERE "player_profiles"."profileable_id" = $1 AND "player_profiles"."profileable_type" = $2 LIMIT $3  [["profileable_id", 1], ["profileable_type", "MotherItem"], ["LIMIT", 1]]
  SQL (0.5ms)  DELETE FROM "player_profiles" WHERE "player_profiles"."id" = $1  [["id", 1]]
  SQL (0.3ms)  DELETE FROM "game_items" WHERE "game_items"."id" = $1  [["id", 1]]
  PlayerProfile Load (0.5ms)  SELECT  "player_profiles".* FROM "player_profiles" WHERE "player_profiles"."profileable_id" = $1 AND "player_profiles"."profileable_type" = $2 LIMIT $3  [["profileable_id", 2], ["profileable_type", "MotherItem"], ["LIMIT", 1]]
  SQL (0.6ms)  DELETE FROM "player_profiles" WHERE "player_profiles"."id" = $1  [["id", 2]]
  SQL (0.8ms)  DELETE FROM "game_items" WHERE "game_items"."id" = $1  [["id", 2]]
  PlayerProfile Load (0.6ms)  SELECT  "player_profiles".* FROM "player_profiles" WHERE "player_profiles"."profileable_id" = $1 AND "player_profiles"."profileable_type" = $2 LIMIT $3  [["profileable_id", 3], ["profileable_type", "MotherItem"], ["LIMIT", 1]]
  SQL (0.5ms)  DELETE FROM "player_profiles" WHERE "player_profiles"."id" = $1  [["id", 3]]
  SQL (0.3ms)  DELETE FROM "game_items" WHERE "game_items"."id" = $1  [["id", 3]]
  PlayerItem Load (0.5ms)  SELECT "sister_items".* FROM "sister_items" WHERE "sister_items"."team_id" = $1  [["team_id", 1]]
  PlayerProfile Load (0.5ms)  SELECT  "player_profiles".* FROM "player_profiles" WHERE "player_profiles"."profileable_id" = $1 AND "player_profiles"."profileable_type" = $2 LIMIT $3  [["profileable_id", 1], ["profileable_type", "PlayerItem"], ["LIMIT", 1]]
  SQL (0.4ms)  DELETE FROM "player_profiles" WHERE "player_profiles"."id" = $1  [["id", 4]]
  SQL (0.4ms)  DELETE FROM "sister_items" WHERE "sister_items"."id" = $1  [["id", 1]]
  PlayerProfile Load (0.6ms)  SELECT  "player_profiles".* FROM "player_profiles" WHERE "player_profiles"."profileable_id" = $1 AND "player_profiles"."profileable_type" = $2 LIMIT $3  [["profileable_id", 2], ["profileable_type", "PlayerItem"], ["LIMIT", 1]]
  SQL (0.5ms)  DELETE FROM "player_profiles" WHERE "player_profiles"."id" = $1  [["id", 5]]
  SQL (0.7ms)  DELETE FROM "sister_items" WHERE "sister_items"."id" = $1  [["id", 2]]
  PlayerProfile Load (0.4ms)  SELECT  "player_profiles".* FROM "player_profiles" WHERE "player_profiles"."profileable_id" = $1 AND "player_profiles"."profileable_type" = $2 LIMIT $3  [["profileable_id", 3], ["profileable_type", "PlayerItem"], ["LIMIT", 1]]
  SQL (0.3ms)  DELETE FROM "player_profiles" WHERE "player_profiles"."id" = $1  [["id", 6]]
  SQL (0.3ms)  DELETE FROM "sister_items" WHERE "sister_items"."id" = $1  [["id", 3]]
  PlayerProfile Load (0.4ms)  SELECT  "player_profiles".* FROM "player_profiles" WHERE "player_profiles"."profileable_id" = $1 AND "player_profiles"."profileable_type" = $2 LIMIT $3  [["profileable_id", 4], ["profileable_type", "PlayerItem"], ["LIMIT", 1]]
  SQL (1.4ms)  DELETE FROM "player_profiles" WHERE "player_profiles"."id" = $1  [["id", 7]]
  SQL (0.5ms)  DELETE FROM "sister_items" WHERE "sister_items"."id" = $1  [["id", 4]]
  PlayerProfile Load (0.5ms)  SELECT  "player_profiles".* FROM "player_profiles" WHERE "player_profiles"."profileable_id" = $1 AND "player_profiles"."profileable_type" = $2 LIMIT $3  [["profileable_id", 5], ["profileable_type", "PlayerItem"], ["LIMIT", 1]]
  SQL (0.5ms)  DELETE FROM "player_profiles" WHERE "player_profiles"."id" = $1  [["id", 8]]
  SQL (0.5ms)  DELETE FROM "sister_items" WHERE "sister_items"."id" = $1  [["id", 5]]
  PlayerProfile Load (0.4ms)  SELECT  "player_profiles".* FROM "player_profiles" WHERE "player_profiles"."profileable_id" = $1 AND "player_profiles"."profileable_type" = $2 LIMIT $3  [["profileable_id", 6], ["profileable_type", "PlayerItem"], ["LIMIT", 1]]
  SQL (1.3ms)  DELETE FROM "player_profiles" WHERE "player_profiles"."id" = $1  [["id", 9]]
  SQL (1.9ms)  DELETE FROM "sister_items" WHERE "sister_items"."id" = $1  [["id", 6]]
  FootballItem Load (1.4ms)  SELECT "football_items".* FROM "football_items" WHERE "football_items"."team_id" = $1  [["team_id", 1]]
  PlayerProfile Load (1.0ms)  SELECT  "player_profiles".* FROM "player_profiles" WHERE "player_profiles"."profileable_id" = $1 AND "player_profiles"."profileable_type" = $2 LIMIT $3  [["profileable_id", 1], ["profileable_type", "FootballItem"], ["LIMIT", 1]]
  SQL (0.6ms)  DELETE FROM "player_profiles" WHERE "player_profiles"."id" = $1  [["id", 10]]
  SQL (1.8ms)  DELETE FROM "football_items" WHERE "football_items"."id" = $1  [["id", 1]]
  PlayerProfile Load (0.3ms)  SELECT  "player_profiles".* FROM "player_profiles" WHERE "player_profiles"."profileable_id" = $1 AND "player_profiles"."profileable_type" = $2 LIMIT $3  [["profileable_id", 2], ["profileable_type", "FootballItem"], ["LIMIT", 1]]
  SQL (1.0ms)  DELETE FROM "player_profiles" WHERE "player_profiles"."id" = $1  [["id", 11]]
  SQL (0.4ms)  DELETE FROM "football_items" WHERE "football_items"."id" = $1  [["id", 2]]
  PlayerProfile Load (0.4ms)  SELECT  "player_profiles".* FROM "player_profiles" WHERE "player_profiles"."profileable_id" = $1 AND "player_profiles"."profileable_type" = $2 LIMIT $3  [["profileable_id", 3], ["profileable_type", "FootballItem"], ["LIMIT", 1]]
  SQL (0.6ms)  DELETE FROM "player_profiles" WHERE "player_profiles"."id" = $1  [["id", 12]]
  SQL (0.6ms)  DELETE FROM "football_items" WHERE "football_items"."id" = $1  [["id", 3]]
  PlayerProfile Load (0.9ms)  SELECT  "player_profiles".* FROM "player_profiles" WHERE "player_profiles"."profileable_id" = $1 AND "player_profiles"."profileable_type" = $2 LIMIT $3  [["profileable_id", 4], ["profileable_type", "FootballItem"], ["LIMIT", 1]]
  SQL (0.6ms)  DELETE FROM "player_profiles" WHERE "player_profiles"."id" = $1  [["id", 13]]
  SQL (0.3ms)  DELETE FROM "football_items" WHERE "football_items"."id" = $1  [["id", 4]]
  SQL (2.7ms)  DELETE FROM "teams" WHERE "teams"."id" = $1  [["id", 1]]
  SQL (2.6ms)  DELETE FROM "products" WHERE "products"."id" = $1  [["id", 1]]
   (3.3ms)  COMMIT
Completed 200 OK in 522ms (Views: 0.4ms | ActiveRecord: 80.5ms)

user: root
POST /api/v1
USE eager loading detected
  MotherItem => [:player_profile]
  Add to your finder: :includes => [:player_profile]
Call stack
  /app/graphql/delete.rb:15:in `call'
  /app/controllers/graphql_controller.rb:11:in `execute'

user: root
POST /api/v1
USE eager loading detected
  PlayerItem => [:player_profile]
  Add to your finder: :includes => [:player_profile]
Call stack
  /app/graphql/delete.rb:15:in `call'
  /app/controllers/graphql_controller.rb:11:in `execute'

user: root
POST /api/v1
USE eager loading detected
  FootballItem => [:player_profile]
  Add to your finder: :includes => [:player_profile]
Call stack
  /app/graphql/delete.rb:15:in `call'
  /app/controllers/graphql_controller.rb:11:in `execute'

As you can see gem Bullet (https://github.com/flyerhzm/bullet) detects N+1 problems...

Is there a way to handle this? Maybe with the same gem for batching and preloader? (https://github.com/Shopify/graphql-batch) - (https://github.com/ConsultingMD/graphql-preload) ?

Warming the cache

We load some records to place into the graphql context before running a query. I'd like to warm the graphql batch loader caches with these records. At the moment I'm doing something like this (with a contrived example):

# app/graphql/record_loader.rb
class RecordLoader < GraphQL::Batch::Loader
  def initialize(record_class)
    @record_class = record_class
    @primary_key = record_class.primary_key
  end

  def perform(keys)
    # Resolve unfulfilled keys
    records_by_key = @record_class.where(@primary_key => keys).index_by(&@primary_key)

    # Fulfills both found keys as records, and unfound keys as nil
    keys.each { |key| fulfill(key, records_by_key[key]) }
  end

  def warm(record)
    key = record.public_send(@primary_key)
    promise = cache[cache_key(key)] ||= Promise.new.tap { |promise| promise.source = self }
    promise.fulfill(record) unless promise.fulfilled?
  end
end
# app/graphql/schema.rb
Schema = GraphQL::Schema.define do
  # ...
  use GraphQL::Batch
  use GraphQL::Batch::Warmer, with: -> (query) do
    RecordLoader.for(User).warm(query.context[:current_user])
  end
  # ...
end
# lib/graphql/batch/warmer.rb
module GraphQL::Batch::Warmer
  def self.use(schema_defn, with:)
    schema_defn.instrument(:query, QueryInstrumenter.new(with))
  end

  class QueryInstrumenter
    def initialize(block)
      @block = block
    end

    def before_query(query)
      unless GraphQL::Batch::Executor.current
        raise "Move `use GraphQL::BatchWarmer` below `use GraphQL::Batch` in your schema, or remove it"
      end

      @block.call(query)
    end

    def after_query(query)
    end
  end
end

This feels like it dips a little too much into this gem's responsibilities. Is there any interest including some sort of cache warming facility in this gem?

Update: I've simplified the example.

Cannot re-use loaders

I was trying to reuse a loader, but if the second load provides a different input id it will crash with GraphQL::Batch::BrokenPromiseError: Promise wasn't fulfilled after all queries were loaded.

Example:

loader = SomeLoader.for
loader.load(1).sync # First load
loader.load(1).sync # Everything's okay. Record already loaded.

loader.load(2).sync # Crashes.
# gems/promise.rb-0.7.1/lib/promise.rb:84:in `block in reject'
# gems/promise.rb-0.7.1/lib/promise.rb:114:in `dispatch'
# gems/promise.rb-0.7.1/lib/promise.rb:82:in `reject'
# gems/graphql-batch-0.2.4/lib/graphql/batch/executor.rb:33:in `wait'
# gems/graphql-batch-0.2.4/lib/graphql/batch/promise.rb:4:in `wait'
# gems/promise.rb-0.7.1/lib/promise.rb:64:in `sync'

It crashes before reaching the #perform method at all.

Is this by design? I think the README should mention it in that case.

Release new Gem version to fix class definition syntax?

Using the latest graphql gem with latest graphql-batch gem yields errors. #101

NoMethodError: undefined method `extension' for nil:NilClass

0.4.0 is behind master for months since Feb 22, 2019 #103

Diff between master and latest release https://github.com/Shopify/graphql-batch/compare/v0.4.0...master?expand=1

Right now we have resorted to pointing ref to latest commit hash on master in our Gemfile, seems to work for now, but are there any plans to release a new gem version like 0.4.1?

Promise Namespacing Issue

There is a Namespacing inconsistency in GraphQL::Batch::Loader between the Promises returned by load and load_many causing them to return different classes.

   def load(key)
      cache[cache_key(key)] ||= begin
        queue << key
        Promise.new.tap { |promise| promise.source = self } ### returns GraphQL::Batch::Promise
      end
    end

    def load_many(keys)
      ::Promise.all(keys.map { |key| load(key) })  ### returns Promise 
    end

Is there a particular reason for this? I can't seem to reason one.

Release master?

Hi, would you mind releasing master to rubygems? 0.3.3 has the caching patch which was removed on master. Master is testing a bit better for but it's easier for our CI to point at rubygems. Could you cut a new version?

[Question] Objects, structs or hashes as loader key

Hello,

Is it okay to use a struct, hash or object as loader key?

Loader.for(Product, key: :id).load(obj) # pass whole object to loader

At first it does not sound like a good idea. I want the object to be available inside the loader and perform authorization there. Sometimes you need to query for another resource during authorization and to avoid N+1 it has to happen inside the loader. I can provide more details about the issue but the answer would be appreciated ๐Ÿ‘

Perhaps something like this would be pretty useful.

Loader.for(Product).load(obj.id, obj) # optionally pass object

Clarify error handling

Given the following query

query profileRefQuery {
  me { 
    profile_visits {     
      collection {
       number_of_visits
        visitor_id
        visitor {
          ...profileFragment
        }
      }
      total
    }
  }
  
  profile_refs(ids: [1]){
    ... profileFragment
  }
}

on the following type system

  Me = GraphQL::ObjectType.define do
    name "me"
   
    field :profile_ref do
      type Types::Profiles::ProfileRef
      resolve Resolvers::Profiles::ProfileRef
    end
  end

  QueryType = GraphQL::ObjectType.define do
    name "Query"

    field :profile_refs do
      type types[!Types::Profiles::ProfileRef]
      argument :ids, !types.ID.to_list_type

      resolve ->(obj, args, context) {
        Promise.all(args["ids"].map { |id| Resolvers::Profiles::ProfileRef.call(obj, {"id" => id}, context) })
      }
    end

    field :me do
      type Me
      resolve ->(_,_,_){ {} }
    end
  end

how would I reasonably indicate errors? My Batch::Loader is calling into a different system via HTTP/Rest, which can of course have timeouts.

Currently I raise an GraphQL::ExecutionError from my perform(ids) method when it runs into a timeout. That sort of works.

The only nasty side effect of this is that in the response all paths are wrong (because they point to the resolved promise).

{
  "data": {
    "me": {
      "profile_visits": {
        "collection": [
          {
            "number_of_visits": 2,
            "visitor_id": "2",
            "visitor": null
          },
          {
            "number_of_visits": 2,
            "visitor_id": "15",
            "visitor": null
          },
          {
            "number_of_visits": 1,
            "visitor_id": "2",
            "visitor": null
          },
          {
            "number_of_visits": 1,
            "visitor_id": "15",
            "visitor": null
          }
        ],
        "total": 4
      }
    },
    "profile_refs": null
  },
  "errors": [
    {
      "message": "BACKEND_TIMEOUT",
      "locations": [
        {
          "line": 29,
          "column": 3
        }
      ],
      "path": [
        "profile_refs"
      ],
      "detail": {}
    },
    {
      "message": "BACKEND_TIMEOUT",
      "locations": [
        {
          "line": 29,
          "column": 3
        }
      ],
      "path": [
        "profile_refs"
      ],
      "detail": {}
    },
    {
      "message": "BACKEND_TIMEOUT",
      "locations": [
        {
          "line": 29,
          "column": 3
        }
      ],
      "path": [
        "profile_refs"
      ],
      "detail": {}
    },
    {
      "message": "BACKEND_TIMEOUT",
      "locations": [
        {
          "line": 29,
          "column": 3
        }
      ],
      "path": [
        "profile_refs"
      ],
      "detail": {}
    },
    {
      "message": "BACKEND_TIMEOUT",
      "locations": [
        {
          "line": 29,
          "column": 3
        }
      ],
      "path": [
        "profile_refs"
      ],
      "detail": {}
    }
  ]
}

Which basically means that a client can't detect that resolving the visitor has failed.

How would you handle this? Any suggestions?

Bullet throws "avoid eager loading" when using AssociationLoader

I don't know if this is a bug or just a dumb question, but let me explain it ๐Ÿ˜„

Starting with this simple type:

class Types::ProjectType < Types::BaseNode
  graphql_name 'Project'

  field :title, String, null: false
  field :milestones, Types::MilestoneType.connection_type, null: false

  def milestones
    object.milestones
  end
end

And running this query:

query {
  projects(perPage: 10) {
    edges {
      node {
        title
        milestones {
          edges {
            node {
              title
            }
          }
        }
      }
    }
  }
}

Returns the desired result, but leads to this:

  Milestone Load (0.9ms)  SELECT milestones.*, milestones.id AS cursor_0 FROM "milestones" WHERE "milestones"."project_id" = $1 ORDER BY milestones.id asc  [["project_id", 1]]
  Milestone Load (0.5ms)  SELECT milestones.*, milestones.id AS cursor_0 FROM "milestones" WHERE "milestones"."project_id" = $1 ORDER BY milestones.id asc  [["project_id", 2]]
  Milestone Load (0.7ms)  SELECT milestones.*, milestones.id AS cursor_0 FROM "milestones" WHERE "milestones"."project_id" = $1 ORDER BY milestones.id asc  [["project_id", 3]]
  Milestone Load (0.6ms)  SELECT milestones.*, milestones.id AS cursor_0 FROM "milestones" WHERE "milestones"."project_id" = $1 ORDER BY milestones.id asc  [["project_id", 4]]
  Milestone Load (0.6ms)  SELECT milestones.*, milestones.id AS cursor_0 FROM "milestones" WHERE "milestones"."project_id" = $1 ORDER BY milestones.id asc  [["project_id", 5]]
  Milestone Load (0.7ms)  SELECT milestones.*, milestones.id AS cursor_0 FROM "milestones" WHERE "milestones"."project_id" = $1 ORDER BY milestones.id asc  [["project_id", 6]]
  Milestone Load (0.5ms)  SELECT milestones.*, milestones.id AS cursor_0 FROM "milestones" WHERE "milestones"."project_id" = $1 ORDER BY milestones.id asc  [["project_id", 7]]
  Milestone Load (0.5ms)  SELECT milestones.*, milestones.id AS cursor_0 FROM "milestones" WHERE "milestones"."project_id" = $1 ORDER BY milestones.id asc  [["project_id", 8]]
  Milestone Load (0.8ms)  SELECT milestones.*, milestones.id AS cursor_0 FROM "milestones" WHERE "milestones"."project_id" = $1 ORDER BY milestones.id asc  [["project_id", 9]]
  Milestone Load (0.8ms)  SELECT milestones.*, milestones.id AS cursor_0 FROM "milestones" WHERE "milestones"."project_id" = $1 ORDER BY milestones.id asc  [["project_id", 10]]

So I thought it would be good to use the AssociationLoader to eager load the milestones using this:

def milestones
  Loaders::AssociationLoader.for(Project, :milestones).load(object)
end

But this ends up in this:

  Milestone Load (0.7ms)  SELECT "milestones".* FROM "milestones" WHERE "milestones"."project_id" IN ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)  [["project_id", 1], ["project_id", 2], ["project_id", 3], ["project_id", 4], ["project_id", 5], ["project_id", 6], ["project_id", 7], ["project_id", 8], ["project_id", 9], ["project_id", 10]]
  Milestone Load (0.5ms)  SELECT milestones.*, milestones.id AS cursor_0 FROM "milestones" WHERE "milestones"."project_id" = $1 ORDER BY milestones.id asc  [["project_id", 1]]
  Milestone Load (0.5ms)  SELECT milestones.*, milestones.id AS cursor_0 FROM "milestones" WHERE "milestones"."project_id" = $1 ORDER BY milestones.id asc  [["project_id", 2]]
  Milestone Load (0.4ms)  SELECT milestones.*, milestones.id AS cursor_0 FROM "milestones" WHERE "milestones"."project_id" = $1 ORDER BY milestones.id asc  [["project_id", 3]]
  Milestone Load (0.7ms)  SELECT milestones.*, milestones.id AS cursor_0 FROM "milestones" WHERE "milestones"."project_id" = $1 ORDER BY milestones.id asc  [["project_id", 4]]
  Milestone Load (0.5ms)  SELECT milestones.*, milestones.id AS cursor_0 FROM "milestones" WHERE "milestones"."project_id" = $1 ORDER BY milestones.id asc  [["project_id", 5]]
  Milestone Load (0.5ms)  SELECT milestones.*, milestones.id AS cursor_0 FROM "milestones" WHERE "milestones"."project_id" = $1 ORDER BY milestones.id asc  [["project_id", 6]]
  Milestone Load (1.0ms)  SELECT milestones.*, milestones.id AS cursor_0 FROM "milestones" WHERE "milestones"."project_id" = $1 ORDER BY milestones.id asc  [["project_id", 7]]
  Milestone Load (0.5ms)  SELECT milestones.*, milestones.id AS cursor_0 FROM "milestones" WHERE "milestones"."project_id" = $1 ORDER BY milestones.id asc  [["project_id", 8]]
  Milestone Load (0.5ms)  SELECT milestones.*, milestones.id AS cursor_0 FROM "milestones" WHERE "milestones"."project_id" = $1 ORDER BY milestones.id asc  [["project_id", 9]]
  Milestone Load (0.6ms)  SELECT milestones.*, milestones.id AS cursor_0 FROM "milestones" WHERE "milestones"."project_id" = $1 ORDER BY milestones.id asc  [["project_id", 10]]
Completed 200 OK in 497ms (Views: 0.4ms | ActiveRecord: 34.6ms)


user: peter_goebel
POST /graphql
AVOID eager loading detected
  Project => [:milestones]
  Remove from your finder: :includes => [:milestones]
Call stack

Why are all milestones still queried individually even with eager loading/preloading?

Issues with graphql-relay 0.9.5 and graphql 0.13

Looks like the interface for connections has changed somehow

NoMethodError - undefined method `offset' for #<Team:0x00000007fc2d60>:
  activemodel (4.2.6) lib/active_model/attribute_methods.rb:433:in `method_missing'
  graphql-relay (0.9.5) lib/graphql/relay/relation_connection.rb:32:in `sliced_nodes'
  graphql-relay (0.9.5) lib/graphql/relay/relation_connection.rb:23:in `paged_nodes'
  graphql-relay (0.9.5) lib/graphql/relay/relation_connection.rb:82:in `paged_nodes_array'
  graphql-relay (0.9.5) lib/graphql/relay/relation_connection.rb:5:in `cursor_from_node'
  graphql-relay (0.9.5) lib/graphql/relay/edge.rb:14:in `cursor'
  graphql (0.13.0) lib/graphql/field.rb:111:in `block in build_default_resolver'
  graphql (0.13.0) lib/graphql/field.rb:67:in `resolve'
  graphql (0.13.0) lib/graphql/query/serial_execution/field_resolution.rb:69:in `block in get_middleware_proc_from_field_resolve'
  graphql (0.13.0) lib/graphql/schema/middleware_chain.rb:24:in `call'
  graphql (0.13.0) lib/graphql/schema/rescue_middleware.rb:34:in `call'
  graphql (0.13.0) lib/graphql/schema/middleware_chain.rb:24:in `call'
  graphql (0.13.0) lib/graphql/query/serial_execution/field_resolution.rb:58:in `get_raw_value'
  graphql (0.13.0) lib/graphql/query/serial_execution/field_resolution.rb:26:in `result'
  graphql (0.13.0) lib/graphql/query/serial_execution/selection_resolution.rb:98:in `resolve_field'
  graphql (0.13.0) lib/graphql/query/serial_execution/selection_resolution.rb:18:in `block in result'
  graphql (0.13.0) lib/graphql/query/serial_execution/selection_resolution.rb:17:in `result'
  graphql (0.13.0) lib/graphql/query/serial_execution/value_resolution.rb:72:in `non_null_result'
  graphql (0.13.0) lib/graphql/query/serial_execution/value_resolution.rb:23:in `result'
  graphql (0.13.0) lib/graphql/query/serial_execution/field_resolution.rb:45:in `get_finished_value'
   () home/andrew/bundle/ruby/2.3.0/bundler/gems/graphql-batch-9cf8341196a8/lib/graphql/batch/execution_strategy.rb:56:in `get_finished_value'
  graphql (0.13.0) lib/graphql/query/serial_execution/field_resolution.rb:30:in `result'
  graphql (0.13.0) lib/graphql/query/serial_execution/selection_resolution.rb:98:in `resolve_field'
  graphql (0.13.0) lib/graphql/query/serial_execution/selection_resolution.rb:18:in `block in result'
  graphql (0.13.0) lib/graphql/query/serial_execution/selection_resolution.rb:17:in `result'
  graphql (0.13.0) lib/graphql/query/serial_execution/value_resolution.rb:72:in `non_null_result'
  graphql (0.13.0) lib/graphql/query/serial_execution/value_resolution.rb:23:in `result'
  graphql (0.13.0) lib/graphql/query/serial_execution/field_resolution.rb:45:in `get_finished_value'
   () home/andrew/bundle/ruby/2.3.0/bundler/gems/graphql-batch-9cf8341196a8/lib/graphql/batch/execution_strategy.rb:56:in `get_finished_value'
  graphql (0.13.0) lib/graphql/query/serial_execution/field_resolution.rb:30:in `result'
  graphql (0.13.0) lib/graphql/query/serial_execution/selection_resolution.rb:98:in `resolve_field'
  graphql (0.13.0) lib/graphql/query/serial_execution/selection_resolution.rb:18:in `block in result'
  graphql (0.13.0) lib/graphql/query/serial_execution/selection_resolution.rb:17:in `result'
  graphql (0.13.0) lib/graphql/query/serial_execution/operation_resolution.rb:20:in `result'
  graphql (0.13.0) lib/graphql/query/serial_execution.rb:17:in `execute'
   () home/andrew/bundle/ruby/2.3.0/bundler/gems/graphql-batch-9cf8341196a8/lib/graphql/batch/execution_strategy.rb:4:in `execute'
  graphql (0.13.0) lib/graphql/query/executor.rb:38:in `execute'
  graphql (0.13.0) lib/graphql/query/executor.rb:15:in `result'
  graphql (0.13.0) lib/graphql/query.rb:47:in `result'
  graphql (0.13.0) lib/graphql/schema.rb:55:in `execute'
  app/controllers/api/v1/graph_ql_controller.rb:14:in `execute'

Using Rails cache to cache results of graphql-batch promises?

I currently have graphql-ruby acting as a front end for our LDAP directory. graphql-batch works really great against directory data. I've even gone so far as to cache the ldap query results that are formed by the batch loaders using Rails.cache statement.

But I'd like the caching to be a bit more granular (i.e. cache individual objects, and not entire sets of objects returned by batch queries.

The problem is when I try to wrap an ObjectLoader.for statement in Rails.cache.fetch block I get an
TypeError (no _dump_data is defined for class Proc):
error.

This seems be because Rails.cache can't cache a promise. Any thoughts or approaches on how to cache the results of an individual fulfill from a batch loader?

Heres some sample code from one of my resolvers that causes the above error:

      def call(obj, args, ctx)

		# calculate environment from the top level env ctx
		myEnv = get_root_query_env(ctx,args)

		# lets setup a cache key for this LDAP query
		myCacheKey = "ldap/#{myEnv}/#{args[:dn]}/#{Base64.encode64(LDAP::LDAP_USER_READABLE_ATTRS.to_s)}"
		Rails.logger.debug "Checking Rails Cache for key for LDAP User DN: #{args[:dn]}"

		# get cached result or if not cached, do actual query
		Rails.cache.fetch(myCacheKey, expires_in: LDAP::LDAP_GLBL_CACHE_EXPIRE_MINS.minutes) do

			# send the request for this user DN to the batch loader
			get_user_from_batch(myEnv, args[:dn])
		end
	end

	private

	def get_user_from_batch( myEnv, dn)
		# Call Generic directory Object loader so that this query can be batched
		Dir::Loaders::ObjectLoader.for("ldap", myEnv, "user").load(dn).then(nil, lambda do |exc|
				# looks like this query errored or wasn't found
				Rails.logger.error exc 
				GraphQL::ExecutionError.new("#{exc}: Object Not Found")
			end ) do |thisObj|
				# return the resolved object from the batch loader
				thisObj
		end
	end

Examples of field resolution affecting parent loader strategy

[This is not a bug, but I don't know where else to put this]

I am trying to wrap my head around what I think is a pretty common use case, and am hoping there's some prior art from others I can learn from. The high level summary is that, given the following query:

query fetchProperty {
  property(id: 1) { 
    id
    name
  }
  property(id: 2) { 
    id
    name
  }
}

I want my batch loader to generate the SQL SELECT id, name FROM properties WHERE id IN (1, 2). Now I have built a loader to generate SELECT * FROM properties WHERE id IN (1, 2) without issues, but I am struggling to understand if / how its possible to have the sub-fields of the field requested affect the loader's strategy.

Relevant code for query:

module GraphType
  QueryType = GraphQL::ObjectType.define do
    field :property, -> { PropertyType } do
      argument :id, !types.ID
      resolve -> (obj, args, ctx) {
        # this is a simple ActiveRecord batch loader that aggregates IDs and
        # fetches them with `.where(id: ids)`
        GraphTool::FindLoader.for(Property.all).load(args[:id])
      }
    end
  end
end


module GraphType
  PropertyType = GraphQL::ObjectType.define do
    field :id, !types.ID
    field :name, !types.String
    field :address, !types.String
    field :email, !types.String
  end
end

I guess what I'm asking is, is there a context available where the property promise has not yet been resolved, but property.name's resolve function is being executed?

Instrumentation

Following up from rmosolgo/graphql-ruby#354 (comment):

@dylanahsmith :

For graphql-batch I would just recommend using Promise.map_value to get the end of the field resolution:

class MyFieldInstrumentation
  def instrument(type, field)
    old_resolve_proc = field.resolve_proc
    new_resolve_proc = ->(obj, args, ctx) {
      Rails.logger.info("#{type.name}.#{field.name} START: #{Time.now.to_i}")
      result = old_resolve_proc.call(obj, args, ctx)
      Promise.map_value(result) do |value|
        Rails.logger.info("#{type.name}.#{field.name} END: #{Time.now.to_i}")
        value
      end
    }

    field.redefine do
      resolve(new_resolve_proc)
    end
  end
end

Actually the above does not work (or at least not in a particularly useful way). The problem is that the executor does not necessarily run the loader associated with the field immediately, instead it may wait for some other loaders (or fields I suppose) to execute first. So the "start time" logged above (when the field is added to the loader), is not really "correct", in terms of the work done to fetch the field.

As an example, if I run a query against our example GitHunt server that looks something like:

{
  entries {
     # this field goes to a batch loader that talks to github
     postedBy
     # this field goes to another github batch loader
     repository
     # this field goes to a simple SQL batch loader
     vote
  }
}

You end up seeing traces that look like:

screenshot 2016-10-28 08 54 09

Note in the above that the start time of all the fields is more or less the same (as the executor runs over the set of entries, and they are all added to the various loaders), and the total time is "cumulative". In actuality the vote loader is more or less instantaneous (running against a local sqlite db in this case), and a correct start time for those fields should really be at the end of the repository loader (so I guess 2.03ms in this SS).


This is why I think for proper instrumentation of the batch loader I think we need to know two things:

  1. What time a loader actually starts.
  2. Which fields the loader is running for.

The first part is trivial I suppose, but the second seems tricky.

[graphql v1.10.0.pre2] undefined method `target`

The 1.10.x development branch of graphql-ruby has introduced an issue with graphql-batch.

error

|| Failure/Error: use GraphQL::Batch
|| 
|| NoMethodError:
||   undefined method `target' for ApiSchema:Class
|| # /Users/damon/.gem/ruby/2.6.3/gems/graphql-batch-0.4.1/lib/graphql/batch.rb:19:in `use'
|| # /Users/damon/.gem/ruby/2.6.3/bundler/gems/graphql-ruby-432465c39f72/lib/graphql/schema.rb:839:in `use'
|| # ./app/graphql/api_schema.rb:19:in `<class:ApiSchema>'
|| # ./app/graphql/api_schema.rb:3:in `<main>'

@swalkinshaw suggests to conditionally check if schema_defn responds to target at: https://github.com/Shopify/graphql-batch/blob/master/lib/graphql/batch.rb#L19

I am not sure I fully understand what is going on with schema and how to approach it when target is not present. Happy to help if someone can point me in the right direction.

Originally posted this at graphql-ruby but was suggested I post it here.

diff of gemfile for revision reference

M Gemfile.lock
@@ -1,9 +1,9 @@
 GIT
   remote: git://github.com/rmosolgo/graphql-ruby.git
-  revision: 87942848cf1f01ca5a54d069da41e286cab137d0
-  ref: 1.10-dev
+  revision: 432465c39f72aa7fb108f9e66d94fea6b0e2d4af
+  tag: v1.10.0.pre2
   specs:
-    graphql (1.10.0.pre1)
+    graphql (1.10.0.pre2)

[proposal] Integrate Rubocop

Opening this issue to gauge interest in configuring rubocop for this project. I'd be happy to work on a PR to support this, but given the level of effort wanted to confirm the maintainers interest in introducing linting.

Sample PR to demo some rules that might be applied by default + integration in CI / gemspec (does not contain all fixes): #118

Use different column then id for DB-Loading

Hey everyone,
i am searching for a possibility use the Loader for other columns then the key-column to work with the following case:
I have some ContactModes and every ContactMode is either an Email or a Telephone number, the problem with the Loader is that the foreign-key is in the other table so I have to load email.contact_mode_id == self.id instead of email.id == contact_mode.email_id

In Types::ContactModeType:
  field :email do
    type types[Types::EmailType]
    resolve -> (contact_mode, args, ctx){
      Email.where(contact_mode_id: contact_mode.id)
    }
  end
  field :address do
    type types[Types::AddressType]
    resolve -> (contact_mode, args, ctx) {
      Address.where(contact_mode_id: contact_mode.id)
    }
  end

Is there any possibility to call the loader use Table.column instead of Table.id for Loading?
Thanks for answer

Does not seem to work with unions

The resolve_type seems to get the promise rather than the solved value:

  Target = GraphQL::UnionType.define do
    name 'MyTarget'

    possible_types [X::Type, Y::Type]
    resolve_type ->(obj, _ctx) do
        binding.pry #                            -> obj.class == GraphQL::Batch::Promise
        case o
          when X then X::Type
          when Y then Y::Type
        end
      end
    end

  Type = GraphQL::ObjectType.define do
    name 'MyType'

    field :id, !GraphQL::Common::ObjectId

    field :targets, !types[!Target]  do
      resolve ->(obj, _args, _ctx) do
        case obj.target_type
          when 'X' then obj.targets.map { |t| RecordLoader.for(X).load(t) }
          when 'Y' then obj.targets.map { |t| RecordLoader.for(Y).load(t) }
        end
      end
    end

clarify GraphQL::Batch.batch documentation

I want to use GraphQL::Batch to load arguments for running mutations and it's not clear to me from reading the documentation if it is safe for me to use GraphQL::Batch.batch wherever I feel the need in my application. The note in the documentation refers only to using this in unit tests.

Can you provide clarification on whether GraphQL::Batch.batch can be safely used outside the test environment?

Promises fail to be resolved for Relay connection_type

Relay support in graphql-ruby suggests resolvers for a connection_type should be able to return an array. Expect that graphql-batch would support field resolver returning promise of an array in this scenario. It instead fails with exception No connection implementation to wrap GraphQL::Batch::Promise.

A workaround is to have the resolver wait for promise fulfillment by calling Promise#sync on the Loader#load/#load_many result.

@cjoudrey @dylanahsmith

For a repro, consider this script using graphql (0.19.4) and graphql-batch (0.2.4) on ruby 2.3.0p0 (2015-12-25 revision 53290) [x86_64-darwin15]:

require 'graphql/batch'

class ShopLoader < GraphQL::Batch::Loader
  def initialize(ctx)
  end

  def perform(ids)
    ids.each { |id| fulfill(id, OpenStruct.new({ id: id })) }
  end
end

class ShopResolver
  def self.call(obj, args, ctx)
    ShopLoader.for("ctx").load_many([ 1, 2, 3 ])
  end
end

ShopType = GraphQL::ObjectType.define do
  name "Shop"

  field :id do
    type types.ID
  end
end

QueryType = GraphQL::ObjectType.define do
  name "Query"

  field :shops do
    type types[ShopType]
    resolve ShopResolver
  end

  connection :shopsConnection, ShopType.connection_type do
    resolve ShopResolver
  end
end

Schema = GraphQL::Schema.define do
  query QueryType
end

Schema.query_execution_strategy = GraphQL::Batch::ExecutionStrategy
Schema.mutation_execution_strategy = GraphQL::Batch::MutationExecutionStrategy

puts "Array use of cache loader:"
puts Schema.execute("{ shops { id } }")
# {"data"=>{"shops"=>[{"id"=>"1"}, {"id"=>"2"}, {"id"=>"3"}]}}

puts "Connection use of cache loader:"
puts Schema.execute("{ shopsConnection { edges { node { id } } } }")
# /Users/jocarrie/.rvm/gems/ruby-2.3.0/gems/graphql-0.19.4/lib/graphql/relay/base_connection.rb:37:in `connection_for_nodes': 
# No connection implementation to wrap GraphQL::Batch::Promise (#<GraphQL::Batch::Promise:0x007f8081152170>) (RuntimeError)
#   from /Users/jocarrie/.rvm/gems/ruby-2.3.0/gems/graphql-0.19.4/lib/graphql/relay/connection_resolve.rb:12:in `call'

Is there a way to add errors to the query context from within `RecordLoader#perform`?

After executing a perform to resolve the promises into records, I'd like to be able to add "Record not found" errors for unfound ids to the graphql-ruby error array. However, I don't see a way to do that from with in RecordLoader#perform, or after. Is there a way to access the query context from this method? Or any other mechanism for this?

Thanks ...

Does graphql-batch allow for any sort of composable joins?

Hey, not sure if this is the right project for this but I've been trying to figure out graphql in Ruby and the big issue I can't seem to figure out is how you go from a composed graphQL asking for all sorts of associations to a single SQL query loading all those associations via SQL joins.

Do the good people of Shopify have a solution for this particular problem yet? I get that it's early days still. Would this be in scope for graphql-batch?

Document how one could preload has_many associations

There was some discussion about how one might preload has_many associations in #graphql.

It might be interesting to share some of our AssociationLoader logic or writing a quick tutorial to achieve this in the README.

Batch::Loader and Context

I wasn't able to figure that out from the documentation (and quick glances in the code), could you please clarify a bit here?

How does GraphQL::Batch::Loader interplay with graphql-ruby's resolve context. I don't see any way of accessing it in the GraphQL::Batch::Loader.

Is that intentional because you would loose the ast node context when batching? If the context is not accessible, what's the suggested way of dealing with context data (like the current user_id or something comparable)?

How do you paginate responses when using AssociationLoader?

What's the recommended way to paginate a response when using AssociationLoader?

I've tried the following:

field :posts_connection, Types::PostType.connection_type, null: false
def posts_connection
  AssociationLoader.for(model: User, association_name: :posts).load(object)
end

but this results in two SQL queries being generated, one with my relay-defined pagination queries and one without.

I read through #26 and rmosolgo/graphql-ruby#425 but am unclear on how to use the lazy execution API to solve this. Adding the following to my schema did not help:

lazy_resolve(AssociationLoader, :perform)

Keeping order of records

How would one go to keep the order of records in the output? I have an ordered scope for has_many, but batch loading throws it off, and I don't want to sort in front-end.

optimize question

In graphql-ruby, I've found that every single field of a type is not asynchronizly resolved

# the sleep method represents any blocking method call or IO
field :a, types.String do 
    resolve -> (o, args, c) {
      sleep 3
      "a"
    }
  end

  field :b, types.String do 
    resolve -> (o, args, c) {
      sleep 3 
      "b"
    }
  end

  field :c, types.String do 
    resolve -> (o, args, c) {
      sleep 3 
      "c"
    }
  end

  field :d, types.String do 
    resolve -> (o, args, c) {
      sleep 3 
      "d"
    }
  end

if a query like

test {
    a
    b
    c
    d
  }

the time cost could be

Completed 200 OK in 12602ms (Views: 0.6ms)

seems like the time cost is 3 * 4 = 12 second
I want to get the field concurrently, then the time cost could be nearly 3 second. Is it a good way to use your gem ? Or I should submit a new issue in graphql-ruby ๐Ÿ˜‚

Changelog

I just saw that version 0.3 was released- congratulations! Is there a list of what has changed? I would recommend also checking it into the repo in a changelog file.

Cheers

NoMethodError - undefined method `clear' for nil:NilClass

I'm seeing this stack trace when I use graphql (1.7.5) and graphql-batch (0.3.8) together with subscriptions:

NoMethodError - undefined method `clear' for nil:NilClass:
  graphql-batch (0.3.8) lib/graphql/batch/setup.rb:22:in `ensure in block (2 levels) in
  graphql-batch (0.3.8) lib/graphql/batch/setup.rb:22:in `block (2 levels) in
  graphql (1.7.5) lib/graphql/relay/mutation/resolve.rb:18:in `call'
  graphql (1.7.5) lib/graphql/field.rb:230:in `resolve'
  graphql (1.7.5) lib/graphql/execution/execute.rb:254:in `call'
  graphql (1.7.5) lib/graphql/schema/middleware_chain.rb:45:in `invoke_core'
  graphql (1.7.5) lib/graphql/schema/middleware_chain.rb:38:in `invoke'
  graphql (1.7.5) lib/graphql/execution/execute.rb:108:in `resolve_field'
  graphql (1.7.5) lib/graphql/execution/execute.rb:72:in `block (2 levels) in
  graphql (1.7.5) lib/graphql/tracing.rb:56:in `block in trace'
  graphql (1.7.5) lib/graphql/tracing.rb:70:in `call_tracers'
  graphql (1.7.5) lib/graphql/tracing.rb:56:in `trace'
  graphql (1.7.5) lib/graphql/execution/execute.rb:71:in `block in resolve_selection'
  graphql (1.7.5) lib/graphql/execution/execute.rb:64:in `resolve_selection'
  graphql (1.7.5) lib/graphql/execution/execute.rb:36:in `block in resolve_root_selection'
  graphql (1.7.5) lib/graphql/tracing.rb:56:in `block in trace'
  graphql (1.7.5) lib/graphql/tracing.rb:70:in `call_tracers'
  graphql (1.7.5) lib/graphql/tracing.rb:56:in `trace'
  graphql (1.7.5) lib/graphql/execution/execute.rb:32:in `resolve_root_selection'
  graphql (1.7.5) lib/graphql/execution/multiplex.rb:108:in `begin_query'
  graphql (1.7.5) lib/graphql/execution/multiplex.rb:81:in `block in run_as_multiplex'
  graphql (1.7.5) lib/graphql/execution/multiplex.rb:80:in `run_as_multiplex'
  graphql (1.7.5) lib/graphql/execution/multiplex.rb:69:in `block (2 levels) in run_queries'
  graphql (1.7.5) lib/graphql/execution/multiplex.rb:185:in `with_instrumentation'
  graphql (1.7.5) lib/graphql/execution/multiplex.rb:68:in `block in run_queries'
  graphql (1.7.5) lib/graphql/tracing.rb:56:in `block in trace'
  graphql (1.7.5) lib/graphql/tracing.rb:70:in `call_tracers'
  graphql (1.7.5) lib/graphql/tracing.rb:56:in `trace'
  graphql (1.7.5) lib/graphql/execution/multiplex.rb:58:in `run_queries'
  graphql (1.7.5) lib/graphql/execution/multiplex.rb:48:in `run_all'
  graphql (1.7.5) lib/graphql/schema.rb:299:in `block in multiplex'
  graphql (1.7.5) lib/graphql/schema.rb:606:in `with_definition_error_check'
  graphql (1.7.5) lib/graphql/schema.rb:298:in `multiplex'
  graphql (1.7.5) lib/graphql/schema.rb:275:in `execute'
  app/controllers/graphql_controller.rb:3:in `execute'
  actionpack (4.2.8) lib/action_controller/metal/implicit_render.rb:4:in `send_action'

Any pointers on what's going on here and where to start for a potential fix?

Does not work across multiplexed queries

This library is fantastic and thanks for publishing it!

In my testing, IDs are not always aggregated across multiple queries when Schema#multiplex is used. The promises do resolve correctly, but one DB query is made per multiplexed gql query.

I found a test in this repo, which runs queries with multiplex and uses QueryNotifier.subscriber to assert that only 1 query is made, but my loader does not behave.

Current gem versions:

gem "graphql", "=1.6.0"
gem "graphql-batch", "=0.3.9"

The loader in question is very simple:

module Loaders
  class Single < ::GraphQL::Batch::Loader

    attr_reader :model, :options, :key

    def initialize(model_arg, options_arg = {})
      @model = model_arg
      @options = options_arg
      @key = options.fetch(:key, :id)
    end

    def perform(ids)
      model.where(key => ids).all.each(&method(:fulfill_record))
      ids.reject(&method(:fulfilled?)).each(&method(:fulfill_nil))
    end

    private

    def fulfill_record(record)
      fulfill(record.send(key), record)
    end

    def fulfill_nil(id)
      fulfill(id, nil)
    end

  end
end

Association Loader with scopes

Can graphql-batch be used to load associations with scopes?

I was hoping updating the preload_association method in the AssociationLoader example might do the job like so:

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

However this doesn't appear to work and reverts to n+1 queries when used in graphql. It does work when used outside graphql in a rails console strangely enough. e.g.

ActiveRecord::Associations::Preloader.new.preload( Place.all, :opening_times, OpeningTime.where(x: 'y') )

Detect N+1 Queries

Is there any tool like bullet gem to detect N+1 queries in GraphQL resolvers. Does Bullet gem will detect it?

Rails setup

Hey Team, what's best practices for using graphql-batch on a Rails app? Am I on the right track here?

app/graphql/loaders/record_loader.rb is this common practice?

module Loaders
  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

add path
app/graphql/memair_schema.rb

MemairSchema = GraphQL::Schema.define do
  mutation(Types::MutationType)
  query(Types::QueryType)

  use GraphQL::Batch
end

adding a new field
app/graphql/types/recommendation_type.rb

require 'loaders/record_loader'

Types::RecommendationType = GraphQL::ObjectType.define do
  name 'Recommendation'

  field :id, !types.ID
  field :description, types.String

  field :events, [Types::EventType, null: true], null: false do
    argument :ids, [ID], required: true
  end
  
  def events(ids:)
    RecordLoader.for(Event).load_many(ids)
  end
end

Right now I'm getting: GraphQL::Define::NoDefinitionError (GraphQL::Field can't define 'null'):

But I'll keep digging if I'm on the right path :)

KeyError (key not found: 1)

Hi, I'm getting the following error with my code:

KeyError (key not found: 1):
  
app/graphql/loaders/record_loader.rb:9:in `block in perform'
app/graphql/loaders/record_loader.rb:9:in `perform'
app/controllers/graphql_controller.rb:10:in `execute'

I can't quite figure out what I'm doing wrong, since the code is pretty much copied from the docs.
record_loader.rb:

require 'graphql/batch'

class Loaders::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

and my query resolver:

  field :schoolById, Types::SchoolType do
    description "Get school by id"
    argument :id, types.ID
    resolve -> (obj, args, context) {
      Loaders::RecordLoader.for(School).load_many(args["id"])
    }
  end

I've double checked my code, and args["id"] is a single element, not an array, so #50 isn't the issue. Guessing by the error message, the issue is in fulfill, but I'm not sure how or why. It's probably something small or dumb that I'm missing haha.

Thanks for your time,
Nicholas

through association loader order issue

Hi:

My graphql-ruby version is 1.9.6, and I use the association loader in the example directory

I have these models: Route, RoutesStop, and Stop

Route has this association:
has_many :stops, -> { order 'route_stops.order' }, through: :route_stops

When I use association loader to load stops field in my RouteType, I got this kind of issue:
ActiveRecord::StatementInvalid (PG::UndefinedTable: ERROR: missing FROM-clause entry for table "route_stops"

and the sql it actually execute is:
SELECT "stops".* FROM "stops" WHERE "stops"."id" IN (1, 12) ORDER BY route_stops.order

How can I unscope the order scope of ActiveRecord::Relation to avoid the exception OR what can I do to deal with the exception, under the premise of not removing the scope in the has_many relationship definition

Thanks!

Loader cache is short lived

Hi, for a query like this:

query {
  company(id: 10) {
    job {
      company {
        id
      }
    }
}

I checked that (with my loaders) it will do 3 queries. First to fetch the Company, then Job, then Company again. The last query is cached by ActiveRecord, so it's not a big deal. I am wondering if it should be cached in graphql-batch instead. Both the top level company field and company under Job type are essentially resolved like this: RecordLoader.for(Company).load(10) but the caches seem to live until all direct children and siblings are resolved. In other words, the cache is effectively purged by the time my nested company field is being resolved.

Is there a reason why we couldn't cache these loaders for longer periods of time?

NoMethodError: undefined method `extension' for nil:NilClass when resolving field

After upgrading to the latest versions of graphql and graphql-batch, the resolvers fail to work when they resolve to a lazy value.

โ€บ rg graphql Gemfile.lock
154:    graphql (1.9.1)
155:    graphql-batch (0.4.0)

Error:

NoMethodError: undefined method `extension' for nil:NilClass
graphql-batch-0.4.0/lib/graphql/batch.rb:25:in `block in use'
graphql-batch-0.4.0/lib/graphql/batch.rb:23:in `each'
graphql-batch-0.4.0/lib/graphql/batch.rb:23:in `use'
graphql-1.9.1/lib/graphql/define/defined_object_proxy.rb:28:in `use'
graphql-1.9.1/lib/graphql/schema.rb:781:in `block (2 levels) in to_graphql'
graphql-1.9.1/lib/graphql/schema.rb:777:in `each'
graphql-1.9.1/lib/graphql/schema.rb:777:in `block in to_graphql'
graphql-1.9.1/lib/graphql/define/instance_definable.rb:161:in `instance_eval'
graphql-1.9.1/lib/graphql/define/instance_definable.rb:161:in `ensure_defined'
graphql-1.9.1/lib/graphql/schema.rb:265:in `define'
graphql-1.9.1/lib/graphql/define/instance_definable.rb:130:in `redefine'
graphql-1.9.1/lib/graphql/schema.rb:776:in `to_graphql'
graphql-1.9.1/lib/graphql/schema/member/accepts_definition.rb:120:in `to_graphql'
graphql-1.9.1/lib/graphql/schema.rb:718:in `graphql_definition'
graphql-1.9.1/lib/graphql/schema/field.rb:627:in `with_extensions'
graphql-1.9.1/lib/graphql/schema/field.rb:485:in `resolve'

Cause seems to be #99, and this code:

if Gem::Version.new(GraphQL::VERSION) >= Gem::Version.new('1.9.0.pre3')
require_relative "batch/mutation_field_extension"
if schema.mutation
schema.mutation.fields.each do |name, f|
field = f.metadata[:type_class]
field.extension(GraphQL::Batch::MutationFieldExtension)

The metadata is empty ({}) on the field, so it cannot load a value to call #extension on. Making the if not match, so that part is skipped, makes everything work again.

The type that caused this error is defined using the new class-based syntax, but not all types in the schema is using that syntax. I don't know if it's relevant or not.

This happens both in my test setup (that uses some fixtures and stubs) and with real queries against the schema, so I don't think it's caused by any weird setup on my end.

We do not use the Interpreter and the schema is set up this way:

module MyApiName
  class Schema < GraphQL::Schema
    query QueryRoot
    mutation MutationRoot

    use GraphQL::Batch
    use GraphQL::Backtrace
  end
end

Moving use on top of the other types (which is mentioned as a bad thing in #99) does not change anything either.

Is there anything else I can do here to help debug this? Is there a workaround I can use?

[Question] How to working with resolver ?

I'm trying to use this with a resolver class, e.g:

class EntityResolver < GraphQL::Schema::Resolver
  argument :id, ::GraphQL::Types::ID, required: true

  def resolve(id:)
    ::Loaders::RecordLoader.for(Entity).load(id)
  end
end

In my Schema

field "entity", EntityType, null: false, resolver: EntityResolver

And do query, then I got

`GraphQL::Batch::NoExecutorError - Cannot create loader without an Executor.
Wrap the call to `for` with `GraphQL::Batch.batch` 
or use `GraphQL::Batch::Setup` as a query instrumenter if using with `graphql-ruby`

I've tried GraphQL::Batch.batch { ::Loaders::RecordLoader.for(Entity).load(id) } but got GQL's null error message.

How to working with resolver?

Difficult to integrate graphql-ruby with graphql schema loaded from a `.graphql` file

The README shows how to integrate this gem with your project by calling use from within a GraphQL::Schema subclass:

class MySchema < GraphQL::Schema
  query MyQueryType
  mutation MyMutationType

  use GraphQL::Batch
end

However, for our project, we do not have a schema class; instead we are loading our schema at boot time from a .graphql file:

schema  = GraphQL::Schema.from_definition(graphql_schema_string, default_resolve: ->(*args) { MyResolver.resolve(*args) })

I did some digging through the source code of the graphql gem and this gem, and found that I could integrate this gem by doing the following:

GraphQL::Define::DefinedObjectProxy.new(schema).use(GraphQL::Batch)

GraphQL::Define::DefinedObjectProxy is the only way I could figure out to get this to work, given GraphQL::Batch#use calls target, which, as afar as I can tell, is only defined on GraphQL::Define::DefinedObjectProxy. This works, but took a lot of digging, and it feels like I'm using a private API as I don't think the author of the graphql gem intends DefinedObjectProxy to be something that end-users directly instantiate like this.

Am I missing a simpler way to integrate graphql-batch with my project? Or is there a simple change that could be made to graphql-batch to support GraphQL::Batch.use(schema) or something similar?

Add a changelog

Does this project have a changelog? I want to update from 0.3.3 to 0.3.9 and I just want to make sure nothing breaks.

Thanks!

KeyError, key not found

I'm using the base example with RecordLoader, like this in a resolver
Loaders::RecordLoader.for(Product).load(Product.ids)

I'm getting this error:
KeyError (key not found: 6)

6 is the first id loaded. I think its somehow related to promise_for(load_key) in loader.rb.

Has anyone had this happen?

Concurrency

While I love that the A+ Promise gives us the ability to compose things in a functional context, I found that the current implementation requires quite a bit of monkey-patching and additional types/interfaces to take advantage of the natural concurrency that futures typically give us. Would the team be open to a PR around a basic future/task/IO type that can take advantage of nonblocking IO?

GraphQL queries are a natural fit for running concurrent fetches at each layer: multiple independent Active Record queries, web requests (e.g., Elasticsearch), etc. The interface could even be the same as Promise (though I'd recommend that #then be sugar for #map and #flat_map, and a few additional methods to make things more predictable/composable).

Here's an example for a single resolver:

resolve ->(_, inputs, ctx) {
  Task
    .all(current_user_required_task(ctx), relay_node_task(inputs[:categoryId], ctx))
    .then { |user, category| create_project_task(user, category, inputs[:description]) }
    .then { |project| { project: project } }
}
# *_task naming for explicitness

This mutation can fire off Active Record queries for the current user and category at the same time using Thread. As soon as one fails it short-circuits by raising an exception and it doesn't need to wait for the other query to complete. When both queries succeed, it can immediately flat-map onto another task that creates the project, which maps into the final return value.

A library like GraphQL Batch could further take advantage of concurrency by using Task.all internally at each nested node of the query tree. Fulfilled tasks are memoized, so later queries can be reduced to avoid fetching records that earlier queries have already fetched and combine them afterwards.

There's lots of potential here, but a bit of work to get there. I'm curious about community interest, though, seeing as we're beginning to explore these benefits internally.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.