Giter Club home page Giter Club logo

graphql-ruby-fragment_cache's Introduction

GraphQL::FragmentCache CI

GraphQL::FragmentCache powers up graphql-ruby with the ability to cache response fragments: you can mark any field as cached and it will never be resolved again (at least, while cache is valid). For instance, the following code caches title for each post:

class PostType < BaseObject
  field :id, ID, null: false
  field :title, String, null: false, cache_fragment: true
end

Sponsored by Evil Martians

Getting started

Add the gem to your Gemfile gem 'graphql-fragment_cache' and add the plugin to your schema class (make sure to turn interpreter mode on with AST analysis!):

class GraphqSchema < GraphQL::Schema
  use GraphQL::Execution::Interpreter
  use GraphQL::Analysis::AST

  use GraphQL::FragmentCache

  query QueryType
end

Include GraphQL::FragmentCache::Object to your base type class:

class BaseType < GraphQL::Schema::Object
  include GraphQL::FragmentCache::Object
end

If you're using resolvers — include the module into the base resolver as well:

class Resolvers::BaseResolver < GraphQL::Schema::Resolver
  include GraphQL::FragmentCache::ObjectHelpers
end

Now you can add cache_fragment: option to your fields to turn caching on:

class PostType < BaseObject
  field :id, ID, null: false
  field :title, String, null: false, cache_fragment: true
end

Alternatively, you can use cache_fragment method inside resolver methods:

class QueryType < BaseObject
  field :post, PostType, null: true do
    argument :id, ID, required: true
  end

  def post(id:)
    cache_fragment { Post.find(id) }
  end
end

If you use connections and plan to cache them—please turn on brand new connections hierarchy in your schema:

class GraphqSchema < GraphQL::Schema
  # ...
  use GraphQL::Pagination::Connections
end

Cache key generation

Cache keys consist of implicit and explicit (provided by user) parts.

Implicit cache key

Implicit part of a cache key (its prefix) contains the information about the schema and the current query. It includes:

  • Hex gsdigest of the schema definition (to make sure cache is cleared when the schema changes).
  • The current query fingerprint consisting of a path to the field, arguments information and the selections set.

Let's take a look at the example:

query = <<~GQL
  query {
    post(id: 1) {
      id
      title
      cachedAuthor {
        id
        name
      }
    }
  }
GQL

schema_cache_key = GraphqSchema.schema_cache_key

path_cache_key = "post(id:1)/cachedAuthor"
selections_cache_key = "[#{%w[id name].join(".")}]"

query_cache_key = Digest::SHA1.hexdigest("#{path_cache_key}#{selections_cache_key}")

cache_key = "#{schema_cache_key}/#{query_cache_key}"

You can override schema_cache_key, query_cache_key or path_cache_key by passing parameters to the cache_fragment calls:

class QueryType < BaseObject
  field :post, PostType, null: true do
    argument :id, ID, required: true
  end

  def post(id:)
    cache_fragment(query_cache_key: "post(#{id})") { Post.find(id) }
  end
end

Overriding path_cache_key might be helpful when you resolve the same object nested in multiple places (e.g., Post and Comment both have author), but want to make sure cache will be invalidated when selection set is different.

Same for the option:

class PostType < BaseObject
  field :id, ID, null: false
  field :title, String, null: false, cache_fragment: {query_cache_key: "post_title"}
end

User-provided cache key

In most cases you want your cache key to depend on the resolved object (say, ActiveRecord model). You can do that by passing an argument to the #cache_fragment method in a similar way to Rails views #cache method:

def post(id:)
  post = Post.find(id)
  cache_fragment(post) { post }
end

You can pass arrays as well to build a compound cache key:

def post(id:)
  post = Post.find(id)
  cache_fragment([post, current_account]) { post }
end

You can omit the block if its return value is the same as the cached object:

# the following line
cache_fragment(post)
# is the same as
cache_fragment(post) { post }

When using cache_fragment: option, it's only possible to use the resolved value as a cache key by setting:

field :post, PostType, null: true, cache_fragment: {cache_key: :object} do
  argument :id, ID, required: true
end

# this is equal to
def post(id:)
  cache_fragment(Post.find(id))
end

Also, you can pass :value to the cache_key: argument to use the returned value to build a key:

field :post, PostType, null: true, cache_fragment: {cache_key: :value} do
  argument :id, ID, required: true
end

# this is equal to
def post(id:)
  post = Post.find(id)
  cache_fragment(post) { post }
end

The way cache key part is generated for the passed argument is the following:

  • Use #graphql_cache_key if implemented.
  • Use #cache_key (or #cache_key_with_version for modern Rails) if implemented.
  • Use self.to_s for primitive types (strings, symbols, numbers, booleans).
  • Raise ArgumentError if none of the above.

Context cache key

By default, we do not take context into account when calculating cache keys. That's because caching is more efficient when it's context-free.

However, if you want some fields to be cached per context, you can do that either by passing context objects directly to the #cache_fragment method (see above) or by adding a context_key option to cache_fragment:.

For instance, imagine a query that allows the current user's social profiles:

query {
  socialProfiles {
    provider
    id
  }
}

You can cache the result using the context (context[:user]) as a cache key:

class QueryType < BaseObject
  field :social_profiles, [SocialProfileType], null: false, cache_fragment: {context_key: :user}

  def social_profiles
    context[:user].social_profiles
  end
end

This is equal to using #cache_fragment the following way:

class QueryType < BaseObject
  field :social_profiles, [SocialProfileType], null: false

  def social_profiles
    cache_fragment(context[:user]) { context[:user].social_profiles }
  end
end

Cache storage and options

It's up to your to decide which caching engine to use, all you need is to configure the cache store:

GraphQL::FragmentCache.cache_store = MyCacheStore.new

Or, in Rails:

# config/application.rb (or config/environments/<environment>.rb)
Rails.application.configure do |config|
  # arguments and options are the same as for `config.cache_store`
  config.graphql_fragment_cache.store = :redis_cache_store
end

⚠️ Cache store must implement #read(key), #exist?(key) and #write_multi(hash, **options) or #write(key, value, **options) methods.

The gem provides only in-memory store out-of-the-box (GraphQL::FragmentCache::MemoryStore). It's used by default.

You can pass store-specific options to #cache_fragment or cache_fragment:. For example, to set expiration (assuming the store's #write method supports expires_in option):

class PostType < BaseObject
  field :id, ID, null: false
  field :title, String, null: false, cache_fragment: {expires_in: 5.minutes}
end

class QueryType < BaseObject
  field :post, PostType, null: true do
    argument :id, ID, required: true
  end

  def post(id:)
    cache_fragment(expires_in: 5.minutes) { Post.find(id) }
  end
end

How to use #cache_fragment in extensions (and other places where context is not available)

If you want to call #cache_fragment from places other that fields or resolvers, you'll need to pass context explicitly and turn on raw_value support. For instance, let's take a look at this extension:

class Types::QueryType < Types::BaseObject
  class CurrentMomentExtension < GraphQL::Schema::FieldExtension
    # turning on cache_fragment support
    include GraphQL::FragmentCache::ObjectHelpers

    def resolve(object:, arguments:, context:)
      # context is passed explicitly
      cache_fragment(context: context) do
        result = yield(object, arguments)
        "#{result} (at #{Time.now})"
      end
    end
  end

  field :event, String, null: false, extensions: [CurrentMomentExtension]

  def event
    "something happened"
  end
end

With this approach you can use #cache_fragment in any place you have an access to the context. When context is not available, the error cannot find context, please pass it explicitly will be thrown.

In–memory fragments

If you have a fragment that accessed from multiple times (e.g., if you have a list of items that belong to the same owner, and owner is cached), you can avoid multiple cache reads by using :keep_in_context option:

class QueryType < BaseObject
  field :post, PostType, null: true do
    argument :id, ID, required: true
  end

  def post(id:)
    cache_fragment(keep_in_context: true, expires_in: 5.minutes) { Post.find(id) }
  end
end

This can reduce a number of cache calls but increase memory usage, because the value returned from cache will be kept in the GraphQL context until the query is fully resolved.

Limitations

Caching does not work for Union types, because of the Lookahead implementation: it requires the exact type to be passed to the selection method (you can find the discussion here). This method is used for cache key building, and I haven't found a workaround yet (PR in progress). If you get Failed to look ahead the field error — please pass query_cache_key explicitly:

field :cached_avatar_url, String, null: false

def cached_avatar_url
  cache_fragment(query_cache_key: "post_avatar_url(#{object.id})") { object.avatar_url }
end

Credits

Based on the original gist by @palkan and @ssnickolay.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/DmitryTsepelev/graphql-ruby-fragment_cache.

License

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

graphql-ruby-fragment_cache's People

Contributors

bbugh avatar dmitrytsepelev avatar palkan avatar reabiliti avatar

Watchers

 avatar

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.