GraphQL::Batch

repository·main·Indexed 23 days ago

https://github.com/shopify/graphql-batch

An executor for the graphql Ruby gem that prevents N+1 problems by batching queries. It provides a Promise-based API and a custom Loader class to resolve data efficiently. Key features include support for multiplexing via SetupMultiplex, cache priming with .prime, and integration into GraphQL schemas to automatically handle mutation field extensions and lazy resolvers.

Tokens
2.6K
Snippets
8
Records
18
Agent score
81%

What's inside graphql-batch

  1. How to implement a custom GraphQL::Batch::Loader

    main

    A loader is a class that inherits from GraphQL::Batch::Loader. It is initialized with arguments used for grouping (e.g., a model class) and must implement a perform method.

    In the perform method, you receive an array of keys (IDs). You should use fulfill(key, value) to resolve each key. If a key cannot be resolved, you should still call fulfill(key, nil) to ensure the promise is settled and doesn't hang.

    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
  2. Install GraphQL::Batch

    main

    To use graphql-batch in your Ruby application, add it to your Gemfile and run bundle:

    gem 'graphql-batch'

    Alternatively, you can install it directly via the command line:

    $ gem install graphql-batch
    gem 'graphql-batch'
  3. Unit testing loaders with GraphQL::Batch.batch

    main

    To test loaders in isolation from a GraphQL query, wrap your batch loads in a GraphQL::Batch.batch block. This method manages the thread-local state required for batching and ensures state is cleared after the block executes.

    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
  4. Implement a custom GraphQL::Batch::Loader

    main

    To batch data fetching, you must create a subclass of GraphQL::Batch::Loader. The core requirement is to override the perform(keys) method. This method is called by the executor once all requested keys have been collected. Inside perform, you should fetch the data for all provided keys and then call fulfill(key, value) for each key to resolve the associated promise.

    If you need to use a different key for the internal cache than the one passed to load, you can override the cache_key(load_key) method.

  5. Integrate GraphQL::Batch into your GraphQL schema

    main

    To enable batching and multiplexing in your GraphQL schema, use the GraphQL::Batch.use method. This method configures the schema to use GraphQL::Batch::SetupMultiplex for multiplexing and ensures that lazy resolvers can handle ::Promise objects by syncing them.

    If your schema includes mutations, GraphQL::Batch.use will also automatically apply the GraphQL::Batch::MutationFieldExtension to all mutation fields to ensure they work correctly within the batching lifecycle.

    You can optionally provide a custom executor_class (defaulting to GraphQL::Batch::Executor).

  6. Integrate GraphQL::Batch with GraphQL-Ruby Multiplexing

    main

    To use GraphQL::Batch within a GraphQL::Schema that utilizes multiplexing, you must configure the schema to start and end batches at the appropriate lifecycle points. This is achieved by providing a setup class that responds to before_multiplex and after_multiplex hooks, or by using a Trace module to wrap the execution.

    Using SetupMultiplex

    When configuring your schema, you can use GraphQL::Batch::SetupMultiplex to ensure the GraphQL::Batch::Executor starts a batch before the multiplexing begins and ends it once the multiplexing is complete.

    Using Trace

    Alternatively, you can implement a Trace module that overrides execute_multiplex to wrap the execution in a start_batch and end_batch block, ensuring the batch is closed even if an error occurs during execution.

  7. Transform query results using Promises

    main

    The load and load_many methods return a Promise (via the promise.rb gem). You can chain transformations using .then.

    If a query depends on the result of a previous one, you can return a new query inside the .then block. If you have multiple independent queries that should be batched together, use Promise.all.

    def product_title(id:)
      RecordLoader.for(Product).load(id).then do |product|
        product.title
      end
    end
    
    # Chaining dependent queries:
    def product_image(id:)
      RecordLoader.for(Product).load(id).then do |product|
        RecordLoader.for(Image).load(product.image_id)
      end
    end
    
    # Batching independent 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
  8. Use loaders in GraphQL field resolvers

    main

    To batch load data within a resolver, use the .for method on your loader class with the necessary grouping arguments, then call .load(key) for a single record or .load_many(keys) for an array of records.

    field :product, Types::Product, null: true do
      argument :id, ID, required: true
    end
    
    def product(id:)
      RecordLoader.for(Product).load(id)
    end
    
    # For multiple records:
    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
  9. Configure GraphQL::Batch in your Schema

    main

    To enable batching and allow the library to automatically clear the cache after mutations, add use GraphQL::Batch to your schema definition. It is recommended to specify this after specifying your mutation type.

    class MySchema < GraphQL::Schema
      query MyQueryType
      mutation MyMutationType
    
      use GraphQL::Batch
    end
  10. Prime the loader cache

    main

    You can manually add key/value pairs to a loader's cache using the .prime(key, value) method. This only adds the entry if the key does not already exist in the cache.

    def liked_products
      liked_products = Product.where(liked: true).load
      liked_products.each do |product|
        RecordLoader.for(Product).prime(product.id, product)
      end
    end
  11. Handle errors and fallbacks with Promise.then

    main

    The .then method accepts two arguments: a lambda for successful resolution and a second lambda for handling exceptions. This is useful for implementing fallback logic, such as attempting to load from a cache and falling back to a database if a connection error occurs.

    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
  12. Manage batching lifecycle with GraphQL::Batch.start_batch and end_batch

    main

    You can manually control the batching lifecycle using GraphQL::Batch.start_batch(executor_class) and GraphQL::Batch.end_batch.

    start_batch initializes a new executor (or retrieves the existing one from the current thread) and increments the nesting level. This is useful when you need to ensure a specific block of code is treated as a batch.

    end_batch decrements the nesting level. If the level reaches below 1, it clears the current executor from the thread. If you attempt to call end_batch without an active executor, it will raise a GraphQL::Batch::NoExecutorError.