graphql-ruby comes with a built–in support for field extensions. You can think of it as it were middlewares: a field that has an extension will be resolved only after passing the extension and only if extension explicitly allows that. An ability to halt the resolution can help a lot, for instance when you want to cache something (when cache is hit there is no need to resolve the field) or check permissions (when check fails—we do not need to continue resolving).

Have no idea what GraphQL is and why people love it? Check out my tutorial about GraphQL in Rails first

Imagine that we have the following schema with a single field we want to cache:

class BaseObject < GraphQL::Schema::Object
end

class QueryType < BaseObject
  field :cached_val, String, null: false

  def cached_val
    "I'm cached at #{Time.now}"
  end
end

class GraphqlSchema < GraphQL::Schema
  query QueryType
end

Obviously nothing is currently cached, so when we call this field multiple times we will get different responses:

query = <<-GQL
  query {
    cachedVal
  }
GQL

puts GraphqlSchema.execute(query).dig("data", "cachedVal") # => I'm cached at 2022-04-07 12:47:22 +0300
sleep 3
puts GraphqlSchema.execute(query).dig("data", "cachedVal") # => I'm cached at 2022-04-07 12:47:26 +0300

Here is the plan: when field is called for the first time, we will resolve it and remember the value, and use the cached one next time field is called.

Our cache implementation will be very naive, please do not do it in your production app 🙂 I cache GraphQL responses using graphql-ruby-fragment_cache

Here is the implementation:

class CacheExtension < GraphQL::Schema::FieldExtension
  def resolve(object:, arguments:, **rest)
    key = cache_key(object, arguments)
    store[key] ||= yield(object, arguments)
  end

  private

  def store
    Thread.current[:field_cache] ||= {}
  end

  def cache_key(object, arguments)
    "#{object.class.name}-#{@field}-#{arguments.hash}"
  end
end

class QueryType < BaseObject
  field :cached_val, String, null: false, extensions: [CacheExtension]

  def cached_val
    "I'm cached at #{Time.now}"
  end
end

Let’s make sure it works (note that response is the same after 3 seconds):

puts GraphqlSchema.execute(query).dig("data", "cachedVal") # => I'm cached at 2022-04-07 15:54:57 +0300
sleep 3
puts GraphqlSchema.execute(query).dig("data", "cachedVal") # => I'm cached at 2022-04-07 15:54:57 +0300

Looking good, but passing the array to the :extensions looks a bit cumbersome, and it will be even less readable when we decide to configure our extensions. For instance, let’s change our extension to support TTL (time to keep value cached):

class CacheExtension < GraphQL::Schema::FieldExtension
  def resolve(object:, arguments:, **rest)
    key = cache_key(object, arguments)
    cached_value = store[key]

    if cached_value.nil? || ttl_expired?(cached_value)
      resolved_value = yield(object, arguments)
      store[key] = { value: resolved_value, cached_at: Time.now }
      return resolved_value
    end

    cached_value[:value]
  end

  private

  def ttl_expired?(cached_value)
    options[:ttl] && (Time.now - cached_value[:cached_at]) > options[:ttl]
  end

  def store
    Thread.current[:field_cache] ||= {}
  end

  def cache_key(object, arguments)
    "#{object.class.name}-#{@field}-#{arguments.hash}"
  end
end

This is how we’ll have to configure the extension:

class QueryType < BaseObject
  field :cached_val,
        String,
        null: false,
        extensions: [{ CacheExtension => { ttl: 3 } }]

  def cached_val
    "I'm cached at #{Time.now}"
  end
end

..and check that it works:

puts GraphqlSchema.execute(query).dig("data", "cachedVal") # => I'm cached at 2022-04-07 16:29:50 +0300
sleep 1
puts GraphqlSchema.execute(query).dig("data", "cachedVal") # => I'm cached at 2022-04-07 16:29:50 +0300
sleep 3
puts GraphqlSchema.execute(query).dig("data", "cachedVal") # => I'm cached at 2022-04-07 16:29:54 +0300

In my opinion, passing kwargs for each extension would be a bit more readable:

field :cached_val, String, null: false, cached: { ttl: 3 }

Is it possible? Yes, but we’ll need to patch the initializer in our BaseObject:

class BaseObject < GraphQL::Schema::Object
  def initialize(*args, cached: false, **kwargs, &block)
    if cached.is_a?(Hash)
      extension(CachedExtension, **cached)
    elsif cached
      extension(CachedExtension)
    end

    super(*args, **kwargs, &block)
  end
end

It’s important to remove our custom keyword argument from kwargs to avoid passing it to the super call, because parent initializer does not expect it.

Here is a full listing that you can run and play with:

require "bundler/inline"

gemfile do
  source "https://rubygems.org"

  gem "graphql"
end

class CacheExtension < GraphQL::Schema::FieldExtension
  def resolve(object:, arguments:, **rest)
    key = cache_key(object, arguments)
    cached_value = store[key]

    if cached_value.nil? || ttl_expired?(cached_value)
      resolved_value = yield(object, arguments)
      store[key] = { value: resolved_value, cached_at: Time.now }
      return resolved_value
    end

    cached_value[:value]
  end

  private

  def ttl_expired?(cached_value)
    options[:ttl] && (Time.now - cached_value[:cached_at]) > options[:ttl]
  end

  def store
    Thread.current[:field_cache] ||= {}
  end

  def cache_key(object, arguments)
    "#{object.class.name}-#{@field}-#{arguments.hash}"
  end
end

class BaseObject < GraphQL::Schema::Object
  def initialize(*args, cached: false, **kwargs, &block)
    if cached.is_a?(Hash)
      extension(CachedExtension, **cached)
    elsif cached
      extension(CachedExtension)
    end

    super(*args, **kwargs, &block)
  end
end

class QueryType < BaseObject
  field :cached_val, String, null: false, cached: { ttl: 3 }

  def cached_val
    "I'm cached at #{Time.now}"
  end
end

class GraphqlSchema < GraphQL::Schema
  query QueryType
end

query = <<-GQL
  query {
    cachedVal
  }
GQL

puts GraphqlSchema.execute(query).dig("data", "cachedVal") # => I'm cached at 2022-04-07 16:29:50 +0300
sleep 1
puts GraphqlSchema.execute(query).dig("data", "cachedVal") # => I'm cached at 2022-04-07 16:29:50 +0300
sleep 3
puts GraphqlSchema.execute(query).dig("data", "cachedVal") # => I'm cached at 2022-04-07 16:29:54 +0300

That’s all for today! Hopefully this approach will help you to write more idiomatic GraphQL code in Ruby.

Looking for a real world example of this trick? I have one for you

Fun fact: this post is based on my gist created 3 years ago. At the middle of writing this post I decided to check docs and realized that this trick was added there two months ago, but I decided to keep going, cause my example is different anyway 🙂