├── .codeclimate.yml ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .rspec ├── .rubocop.yml ├── .travis.yml ├── .yardopts ├── Appraisals ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Gemfile ├── Gemfile.lock ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console ├── rspec └── setup ├── gemfiles ├── .bundle │ └── config ├── graphql_1.8.gemfile ├── graphql_1.9.gemfile └── with_promises.gemfile ├── graphql-cache.gemspec ├── lib └── graphql │ ├── cache.rb │ └── cache │ ├── deconstructor.rb │ ├── fetcher.rb │ ├── field.rb │ ├── key.rb │ ├── marshal.rb │ ├── rails.rb │ ├── resolver.rb │ └── version.rb ├── spec ├── features │ └── connections_spec.rb ├── graphql │ ├── cache │ │ ├── deconstructor_spec.rb │ │ ├── fetcher_spec.rb │ │ ├── field_spec.rb │ │ ├── key_spec.rb │ │ └── marshal_spec.rb │ └── cache_spec.rb ├── spec_helper.rb └── support │ ├── test_cache.rb │ ├── test_logger.rb │ └── test_macros.rb ├── test_schema.rb └── test_schema ├── factories.rb ├── graphql_schema.rb ├── models.rb └── schema.rb /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | checks: 3 | argument-count: 4 | config: 5 | threshold: 5 6 | complex-logic: 7 | config: 8 | threshold: 4 9 | file-lines: 10 | config: 11 | threshold: 250 12 | method-complexity: 13 | config: 14 | threshold: 5 15 | method-count: 16 | config: 17 | threshold: 20 18 | method-lines: 19 | config: 20 | threshold: 25 21 | nested-control-flow: 22 | config: 23 | threshold: 4 24 | return-statements: 25 | config: 26 | threshold: 4 27 | similar-code: 28 | config: 29 | threshold: # language-specific defaults. an override will affect all languages. 30 | identical-code: 31 | config: 32 | threshold: # language-specific defaults. an override will affect all languages. 33 | 34 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG]" 5 | labels: bug 6 | assignees: thebadmonkeydev 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **gem version:** 14 | 15 | **graphql-ruby version:** 16 | 17 | **To Reproduce** 18 | Steps to reproduce the behavior or example schema definition that reproduces the issue. 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Additional context** 24 | Add any other context about the problem here. 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[FEAT]" 5 | labels: enhancement 6 | assignees: thebadmonkeydev 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Additional context** 17 | Add any other context or screenshots about the feature request here. 18 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Problem 2 | ------- 3 | 4 | Cause (for defects/bugs) 5 | ------------------------ 6 | 7 | Solution 8 | -------- 9 | 10 | Notes 11 | ----- 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | 10 | # rspec failure tracking 11 | .rspec_status 12 | 13 | *.gem 14 | 15 | gemfiles/*.gemfile.lock 16 | 17 | .tool-versions 18 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | TargetRubyVersion: '2.2.0' 3 | Exclude: 4 | - 'spec/**/*' 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: ruby 3 | rvm: 4 | - 2.7 5 | - 2.6 6 | - 2.5 7 | - 2.4 8 | - 2.3 9 | gemfile: 10 | - gemfiles/graphql_1.8.gemfile 11 | - gemfiles/graphql_1.9.gemfile 12 | - gemfiles/with_promises.gemfile 13 | before_script: 14 | - curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter 15 | - chmod +x ./cc-test-reporter 16 | - ./cc-test-reporter before-build 17 | script: "bundle exec rake" 18 | after_script: 19 | - ./cc-test-reporter after-build --exit-code $TRAVIS_TEST_RESULT 20 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --no-private 2 | --markup=markdown 3 | --readme=README.md 4 | --title='GraphQL Cache API documentation' 5 | 'lib/**/*.rb' - '*.md' 6 | 7 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | appraise "graphql-1.8" do 2 | gem 'graphql', '~> 1.8' 3 | end 4 | 5 | appraise "graphql-1.9" do 6 | gem 'graphql', '~> 1.9' 7 | end 8 | 9 | appraise "with-promises" do 10 | gem 'promise.rb' 11 | end 12 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at michaelkelly322@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## How to contribute to GraphQL Cache 2 | 3 | #### **Did you find a bug?** 4 | 5 | * **Ensure the bug was not already reported** by searching on GitHub under [Issues](https://github.com/Leanstack/graphql-cache/issues). 6 | 7 | * If you're unable to find an open issue addressing the problem, [open a new one](https://github.com/Leanstack/graphql-cache/issues/new). Be sure to include a **title and clear description**, as much relevant information as possible, and a **code sample** or an **executable test case** demonstrating the expected behavior that is not occurring. 8 | 9 | #### **Did you write a patch that fixes a bug?** 10 | 11 | * Open a new GitHub pull request with the patch. 12 | 13 | * Ensure the PR description clearly describes the problem and solution. Include the relevant issue number if applicable. 14 | 15 | #### **Do you intend to add a new feature or change an existing one?** 16 | 17 | * Open an issue on GitHub and label it "Enhancement" so that the community can comment on the feature 18 | 19 | * If you have code, by all means, open a pull request! 20 | 21 | Thanks again for contributing to GraphQL Cache! :tada: 22 | 23 | The StackShare Team 24 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | git_source(:github) {|repo_name| "https://github.com/#{repo_name}" } 4 | 5 | # Specify your gem's dependencies in graphql-cache.gemspec 6 | gemspec 7 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | graphql-cache (0.6.1) 5 | graphql (~> 1, > 1.8) 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | appraisal (2.2.0) 11 | bundler 12 | rake 13 | thor (>= 0.14.0) 14 | codeclimate-test-reporter (1.0.9) 15 | simplecov (<= 0.13) 16 | coderay (1.1.2) 17 | diff-lcs (1.3) 18 | docile (1.1.5) 19 | graphql (1.9.3) 20 | json (2.3.1) 21 | method_source (0.9.2) 22 | mini_cache (1.1.0) 23 | pry (0.12.2) 24 | coderay (~> 1.1.0) 25 | method_source (~> 0.9.0) 26 | rake (13.0.1) 27 | rspec (3.8.0) 28 | rspec-core (~> 3.8.0) 29 | rspec-expectations (~> 3.8.0) 30 | rspec-mocks (~> 3.8.0) 31 | rspec-core (3.8.0) 32 | rspec-support (~> 3.8.0) 33 | rspec-expectations (3.8.2) 34 | diff-lcs (>= 1.2.0, < 2.0) 35 | rspec-support (~> 3.8.0) 36 | rspec-mocks (3.8.0) 37 | diff-lcs (>= 1.2.0, < 2.0) 38 | rspec-support (~> 3.8.0) 39 | rspec-support (3.8.0) 40 | sequel (5.17.0) 41 | simplecov (0.13.0) 42 | docile (~> 1.1.0) 43 | json (>= 1.8, < 3) 44 | simplecov-html (~> 0.10.0) 45 | simplecov-html (0.10.2) 46 | sqlite3 (1.4.0) 47 | thor (0.20.3) 48 | 49 | PLATFORMS 50 | ruby 51 | 52 | DEPENDENCIES 53 | appraisal 54 | codeclimate-test-reporter 55 | graphql-cache! 56 | mini_cache 57 | pry 58 | rake (~> 13.0) 59 | rspec (~> 3.0) 60 | sequel 61 | simplecov 62 | sqlite3 63 | 64 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Michael Kelly 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **NOTE: this project is looking for maintainers.** If you need caching to work for newer versions of graphql-ruby or for connection types, you may want to have a look at the [graphql-fragment_cache](https://github.com/DmitryTsepelev/graphql-ruby-fragment_cache) gem. 2 | 3 | 4 | 5 | # GraphQL Cache 6 | [![Gem Version](https://badge.fury.io/rb/graphql-cache.svg)](https://badge.fury.io/rb/graphql-cache) [![Build Status](https://travis-ci.org/stackshareio/graphql-cache.svg?branch=master)](https://travis-ci.org/stackshareio/graphql-cache) [![Test Coverage](https://api.codeclimate.com/v1/badges/524c0f23ed1dbf0f9338/test_coverage)](https://codeclimate.com/github/stackshareio/graphql-cache/test_coverage) [![Maintainability](https://api.codeclimate.com/v1/badges/524c0f23ed1dbf0f9338/maintainability)](https://codeclimate.com/github/stackshareio/graphql-cache/maintainability) [![Stack Share](https://img.shields.io/badge/tech-stack-0690fa.svg?style=flat)](https://stackshare.io/stackshare/graphql-cache) 7 | 8 | A custom caching plugin for [graphql-ruby](https://github.com/rmosolgo/graphql-ruby) 9 | 10 | ## Goals 11 | 12 | - Provide resolver-level caching for [GraphQL](https://graphql.org) APIs written in ruby 13 | - Configurable to work with or without Rails 14 | - [API Documentation](https://www.rubydoc.info/gems/graphql-cache) 15 | 16 | ## Why? 17 | 18 | At [StackShare](https://stackshare.io) we've been rolling out [graphql-ruby](https://github.com/rmosolgo/graphql-ruby) for several of our new features and found ourselves in need of a caching solution. We could have simply used `Rails.cache` in our resolvers, but this creates very verbose types or resolver classes. It also means that each and every resolver must define it's own expiration and key. GraphQL Cache solves that problem by integrating caching functionality into the [graphql-ruby](https://github.com/rmosolgo/graphql-ruby) resolution process making caching transparent on most fields except for a metadata flag denoting the field as cached. More details on our motivation for creating this [here](https://stackshare.io/posts/introducing-graphql-cache). 19 | 20 | ## Installation 21 | 22 | Add this line to your application's Gemfile: 23 | 24 | ```ruby 25 | gem 'graphql-cache' 26 | ``` 27 | 28 | And then execute: 29 | 30 | ```sh 31 | $ bundle 32 | ``` 33 | 34 | Or install it yourself as: 35 | 36 | ```sh 37 | $ gem install graphql-cache 38 | ``` 39 | 40 | ## Setup 41 | 42 | 1. Use GraphQL Cache as a plugin in your schema. 43 | 44 | ```ruby 45 | class MySchema < GraphQL::Schema 46 | query Types::Query 47 | 48 | use GraphQL::Cache 49 | end 50 | ``` 51 | 2. Add the custom caching field class to your base object class. This adds the `cache` metadata key when defining fields. 52 | ```ruby 53 | module Types 54 | class Base < GraphQL::Schema::Object 55 | field_class GraphQL::Cache::Field 56 | end 57 | end 58 | ``` 59 | _Also note that if you want access to the `cache` keyword param in interface fields, the field_class directive must be added to your base interface module as well_ 60 | 61 | ## Configuration 62 | 63 | GraphQL Cache can be configured in an initializer: 64 | 65 | ```ruby 66 | # config/initializers/graphql_cache.rb 67 | 68 | GraphQL::Cache.configure do |config| 69 | config.namespace = 'GraphQL::Cache' # Cache key prefix for keys generated by graphql-cache 70 | config.cache = Rails.cache # The cache object to use for caching 71 | config.logger = Rails.logger # Logger to receive cache-related log messages 72 | config.expiry = 5400 # 90 minutes (in seconds) 73 | end 74 | ``` 75 | 76 | ## Usage 77 | 78 | Any object, list, or connection field can be cached by simply adding `cache: true` to the field definition: 79 | 80 | ```ruby 81 | field :calculated_field, Int, cache: true 82 | ``` 83 | 84 | ### Custom Expirations 85 | 86 | By default all keys will have an expiration of `GraphQL::Cache.expiry` which defaults to 90 minutes. If you want to set a field-specific expiration time pass a hash to the `cache` parameter like this: 87 | 88 | ```ruby 89 | field :calculated_field, Int, cache: { expiry: 10800 } # expires key after 180 minutes 90 | ``` 91 | 92 | ### Custom cache keys 93 | 94 | GraphQL Cache generates a cache key using the context of a query during execution. A custom key can be included to implement versioned caching as well. By providing a `:key` value to the cache config hash on a field definition. For example, to use a custom method that returns the cache key for an object use: 95 | 96 | ```ruby 97 | field :calculated_field, Int, cache: { key: :custom_cache_key } 98 | ``` 99 | 100 | With this configuration the cache key used for this resolved value will use the result of the method `custom_cache_key` called on the parent object. 101 | 102 | ### Forcing the cache 103 | 104 | It is possible to force graphql-cache to resolve and write all cached fields to cache regardless of the presence of a given key in the cache store. This will effectively "renew" any existing cached expirations and warm any that don't exist. To use forced caching, add a value to `:force_cache` in the query context: 105 | 106 | ```ruby 107 | MySchema.execute('{ company(id: 123) { cachedField }}', context: { force_cache: true }) 108 | ``` 109 | 110 | This will resolve all cached fields using the field's resolver and write them to cache without first reading the value at their respective cache keys. This is useful for structured cache warming strategies where the cache expiration needs to be updated when a warming query is made. 111 | 112 | ## Development 113 | 114 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 115 | 116 | To install this gem onto your local machine, run `bundle exec rake install`. 117 | 118 | ## Contributing 119 | 120 | Bug reports and pull requests are welcome on GitHub at https://github.com/stackshareio/graphql-cache. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct. 121 | 122 | ## License 123 | 124 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 125 | 126 | ## Code of Conduct 127 | 128 | Everyone interacting in the graphql-cache project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/stackshareio/graphql-cache/blob/master/CODE_OF_CONDUCT.md).. 129 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task :default => :spec 7 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'bundler/setup' 4 | require 'graphql/cache' 5 | require 'logger' 6 | require 'mini_cache' 7 | require 'sequel' 8 | 9 | # Setup MiniCache for in-memory cache for dev/test 10 | class CacheStore < MiniCache::Store 11 | alias read get 12 | alias write set 13 | alias clear reset 14 | end 15 | 16 | GraphQL::Cache.configure do |config| 17 | config.cache = CacheStore.new 18 | config.logger = Logger.new(STDOUT) 19 | end 20 | 21 | # required after GraphQL::Cache initialization because dev 22 | # schema uses cache and logger objects from it. 23 | require_relative '../test_schema' 24 | 25 | require "pry" 26 | Pry.start 27 | -------------------------------------------------------------------------------- /bin/rspec: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'bundler/setup' 4 | load Gem.bin_path('rspec-core', 'rspec') 5 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /gemfiles/.bundle/config: -------------------------------------------------------------------------------- 1 | --- 2 | BUNDLE_RETRY: "1" 3 | -------------------------------------------------------------------------------- /gemfiles/graphql_1.8.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "graphql", "~> 1.8" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /gemfiles/graphql_1.9.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "graphql", "~> 1.9" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /gemfiles/with_promises.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "promise.rb" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /graphql-cache.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path('lib', __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require 'graphql/cache/version' 6 | 7 | Gem::Specification.new do |s| 8 | s.name = 'graphql-cache' 9 | s.version = GraphQL::Cache::VERSION 10 | s.authors = ['Michael Kelly'] 11 | s.email = ['michaelkelly322@gmail.com'] 12 | 13 | s.summary = 'A caching plugin for graphql-ruby' 14 | s.description = 'A caching plugin for graphql-ruby https://stackshare.io/posts/introducing-graphql-cache' 15 | s.homepage = 'https://github.com/stackshareio/graphql-cache' 16 | s.license = 'MIT' 17 | 18 | s.files = `git ls-files -z`.split("\x0").reject do |f| 19 | f.match(%r{^(test|spec|features)/}) 20 | end 21 | s.require_paths = ['lib'] 22 | s.required_ruby_version = '>= 2.2.0' # bc graphql-ruby requires >= 2.2.0 23 | 24 | s.add_development_dependency 'appraisal' 25 | s.add_development_dependency 'codeclimate-test-reporter' 26 | s.add_development_dependency 'mini_cache' 27 | s.add_development_dependency 'pry' 28 | s.add_development_dependency 'rake', '~> 13.0' 29 | s.add_development_dependency 'rspec', '~> 3.0' 30 | s.add_development_dependency 'sequel' 31 | s.add_development_dependency 'simplecov' 32 | s.add_development_dependency 'sqlite3' 33 | 34 | s.add_dependency 'graphql', '~> 1', '> 1.8' 35 | end 36 | -------------------------------------------------------------------------------- /lib/graphql/cache.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'graphql/cache/version' 4 | require 'graphql/cache/field' 5 | require 'graphql/cache/key' 6 | require 'graphql/cache/marshal' 7 | require 'graphql/cache/fetcher' 8 | 9 | module GraphQL 10 | module Cache 11 | class << self 12 | # An object that must conform to the same API as ActiveSupport::Cache::Store 13 | # @return [Object] Defaults to `Rails.cache` in a Rails environment 14 | attr_accessor :cache 15 | 16 | # Global default cache key expiration time in seconds. 17 | # @return [Integer] Default: 5400 (90 minutes) 18 | attr_accessor :expiry 19 | 20 | # Logger instance to use when logging cache hits/misses. 21 | # @return [Logger] 22 | attr_accessor :logger 23 | 24 | # Global namespace for keys 25 | # @return [String] Default: "GraphQL::Cache" 26 | attr_accessor :namespace 27 | 28 | # Provides for initializer syntax 29 | # 30 | # ``` 31 | # GraphQL::Cache.configure do |c| 32 | # c.namespace = 'MyNamespace' 33 | # end 34 | # ``` 35 | def configure 36 | yield self 37 | end 38 | end 39 | 40 | # Default configuration 41 | @expiry = 5400 42 | @namespace = 'graphql' 43 | 44 | # Called by plugin framework in graphql-ruby to 45 | # bootstrap necessary instrumentation and tracing 46 | # tie-ins 47 | def self.use(schema_def, options: {}) 48 | fetcher = ::GraphQL::Cache::Fetcher.new 49 | schema_def.instrument(:field, fetcher) 50 | end 51 | end 52 | end 53 | 54 | require 'graphql/cache/rails' if defined?(::Rails::Engine) 55 | -------------------------------------------------------------------------------- /lib/graphql/cache/deconstructor.rb: -------------------------------------------------------------------------------- 1 | module GraphQL 2 | module Cache 3 | # GraphQL objects can't be serialized to cache so we have 4 | # to maintain an abstraction between the raw cache value 5 | # and the GraphQL expected object. This class exposes methods 6 | # for deconstructing an object to be stored in cache 7 | # 8 | class Deconstructor 9 | # The raw value to perform actions on. Could be a raw cached value, or 10 | # a raw GraphQL Field. 11 | # 12 | # @return [Object] 13 | attr_accessor :raw 14 | 15 | # A flag indicating the type of object construction to 16 | # use when building a new GraphQL object. Can be one of 17 | # 'array', 'collectionproxy', 'relation', 'lazy'. These values 18 | # have been chosen because it is easy to use the class 19 | # names of the possible object types for this purpose. 20 | # 21 | # @return [String] 'array' or 'collectionproxy' or 'relation' or 'lazy' 22 | attr_accessor :method 23 | 24 | # Initializer helper that generates a valid `method` string based 25 | # on `raw.class.name`. 26 | # 27 | # @return [Object] A newly initialized GraphQL::Cache::Deconstructor instance 28 | def self.[](raw) 29 | build_method = namify(raw.class.name) 30 | new(raw, build_method) 31 | end 32 | 33 | # Ruby-only means of "demodularizing" a string 34 | def self.namify(str) 35 | str.split('::').last.downcase 36 | end 37 | 38 | def initialize(raw, method) 39 | self.raw = raw 40 | self.method = method 41 | end 42 | 43 | # Deconstructs a GraphQL field into a cachable value 44 | # 45 | # @return [Object] A value suitable for writing to cache or a lazily 46 | # resolved value 47 | def perform 48 | if method == 'lazy' 49 | raw.then { |resolved_raw| self.class[resolved_raw].perform } 50 | elsif %(array collectionproxy).include? method 51 | deconstruct_array(raw) 52 | elsif raw.class.ancestors.include? GraphQL::Relay::BaseConnection 53 | raw.nodes 54 | else 55 | deconstruct_object(raw) 56 | end 57 | end 58 | 59 | # @private 60 | def deconstruct_array(raw) 61 | return [] if raw.empty? 62 | 63 | if raw.first.class.ancestors.include? GraphQL::Schema::Object 64 | raw.map(&:object) 65 | elsif array_contains_promise?(raw) 66 | Promise.all(raw).then { |resolved| deconstruct_array(resolved) } 67 | else 68 | raw 69 | end 70 | end 71 | 72 | # @private 73 | def array_contains_promise?(raw) 74 | raw.any? { |element| element.class.name == 'Promise' } 75 | end 76 | 77 | # @private 78 | def deconstruct_object(raw) 79 | if raw.respond_to?(:object) 80 | raw.object 81 | else 82 | raw 83 | end 84 | end 85 | end 86 | end 87 | end 88 | 89 | -------------------------------------------------------------------------------- /lib/graphql/cache/fetcher.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'graphql/cache/resolver' 4 | 5 | module GraphQL 6 | module Cache 7 | # Represents the "instrumenter" passed to GraphQL::Schema#instrument 8 | # when the plugin in initialized (i.e. `use GraphQL::Cache`) 9 | class Fetcher 10 | # Redefines the given field's resolve proc to use our 11 | # custom cache wrapping resolver proc. Called from 12 | # graphql-ruby internals. This is the "instrumenter" 13 | # entrypoint. 14 | # 15 | # @param type [GraphQL::Schema::Object] graphql-ruby passes the defined type for the field being instrumented 16 | # @param field [GraphQL::Schema::Field] graphql-ruby passes the field definition to be re-defined 17 | # @return [GraphQL::Schema::Field] 18 | def instrument(type, field) 19 | return field unless field.metadata[:cache] 20 | 21 | field.redefine { resolve(GraphQL::Cache::Resolver.new(type, field)) } 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/graphql/cache/field.rb: -------------------------------------------------------------------------------- 1 | require 'graphql' 2 | 3 | module GraphQL 4 | module Cache 5 | # Custom field class implementation to allow for 6 | # cache config keyword parameters 7 | class Field < ::GraphQL::Schema::Field 8 | # Overriden to take a new cache keyword argument 9 | def initialize( 10 | *args, 11 | cache: false, 12 | **kwargs, 13 | &block 14 | ) 15 | @cache_config = cache 16 | super(*args, **kwargs, &block) 17 | end 18 | 19 | # Overriden to provide custom cache config to internal definition 20 | def to_graphql 21 | field_defn = super # Returns a GraphQL::Field 22 | field_defn.metadata[:cache] = @cache_config 23 | field_defn 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/graphql/cache/key.rb: -------------------------------------------------------------------------------- 1 | module GraphQL 2 | module Cache 3 | # Represents a cache key generated from the graphql context 4 | # provided when initialized 5 | class Key 6 | # The resolved parent object (object this resolver method is called on) 7 | attr_accessor :object 8 | 9 | # Arguments passed during graphql query execution 10 | attr_accessor :arguments 11 | 12 | # The graphql parent type 13 | attr_accessor :type 14 | 15 | # The graphql field being resolved 16 | attr_accessor :field 17 | 18 | # The current graphql query context 19 | attr_accessor :context 20 | 21 | # Metadata passed to the cache key on field definition 22 | attr_accessor :metadata 23 | 24 | # Initializes a new Key with the given graphql query context 25 | # 26 | # @param obj [Object] The resolved parent object for a field's resolution 27 | # @param args [GraphQL::Arguments] The internal graphql-ruby wrapper for field arguments 28 | # @param type [GraphQL::Schema::Type] The type definition of the parent object 29 | # @param field [GraphQL::Schema::Field] The field being resolved 30 | def initialize(obj, args, type, field, context = {}) 31 | @object = obj.object 32 | @arguments = args 33 | @type = type 34 | @field = field 35 | @context = context 36 | @metadata = field.metadata[:cache] 37 | 38 | @metadata = { cache: @metadata } unless @metadata.is_a?(Hash) 39 | end 40 | 41 | # Returns the string representation of this cache key 42 | # suitable for using as a key when writing to cache 43 | # 44 | # The key is constructed with this structure: 45 | # 46 | # ``` 47 | # namespace:type:field:arguments:object-id 48 | # ``` 49 | def to_s 50 | @to_s ||= [ 51 | GraphQL::Cache.namespace, 52 | type_clause, 53 | field_clause, 54 | arguments_clause, 55 | object_clause 56 | ].flatten.compact.join(':') 57 | end 58 | 59 | # Produces the portion of the key representing the parent object 60 | def object_clause 61 | return nil unless object 62 | 63 | "#{object.class.name}:#{object_identifier}" 64 | end 65 | 66 | # Produces the portion of the key representing the parent type 67 | def type_clause 68 | type.name 69 | end 70 | 71 | # Produces the portion of the key representing the resolving field 72 | def field_clause 73 | field.name 74 | end 75 | 76 | # Produces the portion of the key representing the query arguments 77 | def arguments_clause 78 | @arguments_clause ||= arguments.to_h.to_a.flatten 79 | end 80 | 81 | # @private 82 | def object_identifier 83 | case metadata[:key] 84 | when Symbol 85 | object.send(metadata[:key]) 86 | when Proc 87 | metadata[:key].call(object, context) 88 | when NilClass 89 | guess_id 90 | else 91 | metadata[:key] 92 | end 93 | end 94 | 95 | # @private 96 | def guess_id 97 | return object.cache_key_with_version if object.respond_to?(:cache_key_with_version) 98 | return object.cache_key if object.respond_to?(:cache_key) 99 | return object.id if object.respond_to?(:id) 100 | 101 | object.object_id 102 | end 103 | end 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /lib/graphql/cache/marshal.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'graphql/cache/deconstructor' 4 | 5 | module GraphQL 6 | module Cache 7 | # Used to marshal cache fetches into either writes or reads 8 | class Marshal 9 | # The cache key to marshal around 10 | # 11 | # @return [String] The cache key 12 | attr_accessor :key 13 | 14 | # Initializer helper to allow syntax like 15 | # `Marshal[key].read(config, &block)` 16 | # 17 | # @return [GraphQL::Cache::Marshal] 18 | def self.[](key) 19 | new(key) 20 | end 21 | 22 | # Initialize a new instance of {GraphQL::Cache::Marshal} 23 | def initialize(key) 24 | self.key = key.to_s 25 | end 26 | 27 | # Read a value from cache if it exists and re-hydrate it or 28 | # execute the block and write it's result to cache 29 | # 30 | # @param config [Hash] The object passed to `cache:` on the field definition 31 | # @return [Object] 32 | def read(config, force: false, &block) 33 | # write new data from resolver if forced 34 | return write(config, &block) if force 35 | 36 | cached = cache.read(key) 37 | 38 | if cached.nil? 39 | logger.debug "Cache miss: (#{key})" 40 | write config, &block 41 | else 42 | logger.debug "Cache hit: (#{key})" 43 | cached 44 | end 45 | end 46 | 47 | # Executes the resolution block and writes the result to cache 48 | # 49 | # @see GraphQL::Cache::Deconstruct#perform 50 | # @param config [Hash] The middleware resolution config hash 51 | def write(config) 52 | resolved = yield 53 | 54 | document = Deconstructor[resolved].perform 55 | 56 | with_resolved_document(document) do |resolved_document| 57 | cache.write(key, resolved_document, expires_in: expiry(config)) 58 | 59 | resolved 60 | end 61 | end 62 | 63 | # @private 64 | def with_resolved_document(document) 65 | if document_is_lazy?(document) 66 | document.then { |promise_value| yield promise_value } 67 | else 68 | yield document 69 | end 70 | end 71 | 72 | # @private 73 | def document_is_lazy?(document) 74 | ['GraphQL::Execution::Lazy', 'Promise'].include?(document.class.name) 75 | end 76 | 77 | # @private 78 | def expiry(config) 79 | if config.is_a?(Hash) && config[:expiry] 80 | config[:expiry] 81 | else 82 | GraphQL::Cache.expiry 83 | end 84 | end 85 | 86 | # @private 87 | def cache 88 | GraphQL::Cache.cache 89 | end 90 | 91 | # @private 92 | def logger 93 | GraphQL::Cache.logger 94 | end 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /lib/graphql/cache/rails.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GraphQL 4 | module Cache 5 | if defined?(::Rails) 6 | # Railtie integration used to default {GraphQL::Cache.cache} 7 | # and {GraphQL::Cache.logger} when in a Rails environment. 8 | class Rails < ::Rails::Engine 9 | config.after_initialize do 10 | # default values for cache and logger in Rails if not set already 11 | GraphQL::Cache.cache = ::Rails.cache unless GraphQL::Cache.cache 12 | GraphQL::Cache.logger = ::Rails.logger unless GraphQL::Cache.logger 13 | end 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/graphql/cache/resolver.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GraphQL 4 | module Cache 5 | # Represents the caching resolver that wraps the existing resolver proc 6 | class Resolver 7 | attr_accessor :type 8 | 9 | attr_accessor :field 10 | 11 | attr_accessor :orig_resolve_proc 12 | 13 | def initialize(type, field) 14 | @type = type 15 | @field = field 16 | end 17 | 18 | def call(obj, args, ctx) 19 | @orig_resolve_proc = field.resolve_proc 20 | 21 | key = cache_key(obj, args, ctx) 22 | 23 | value = Marshal[key].read( 24 | field.metadata[:cache], force: ctx[:force_cache] 25 | ) do 26 | @orig_resolve_proc.call(obj, args, ctx) 27 | end 28 | 29 | wrap_connections(value, args, parent: obj, context: ctx) 30 | end 31 | 32 | protected 33 | 34 | # @private 35 | def cache_key(obj, args, ctx) 36 | Key.new(obj, args, type, field, ctx).to_s 37 | end 38 | 39 | # @private 40 | def wrap_connections(value, args, **kwargs) 41 | # return raw value if field isn't a connection (no need to wrap) 42 | return value unless field.connection? 43 | 44 | # return cached value if it is already a connection object 45 | # this occurs when the value is being resolved by GraphQL 46 | # and not being read from cache 47 | return value if value.class.ancestors.include?( 48 | GraphQL::Relay::BaseConnection 49 | ) 50 | 51 | create_connection(value, args, **kwargs) 52 | end 53 | 54 | # @private 55 | def create_connection(value, args, **kwargs) 56 | GraphQL::Relay::BaseConnection.connection_for_nodes(value).new( 57 | value, 58 | args, 59 | field: field, 60 | parent: kwargs[:parent], 61 | context: kwargs[:context] 62 | ) 63 | end 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/graphql/cache/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GraphQL 4 | module Cache 5 | VERSION = '0.6.1'.freeze 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/features/connections_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | def execute(query, context = {}) 4 | CacheSchema.execute(query, context: context) 5 | end 6 | 7 | RSpec.describe 'caching connection fields' do 8 | let(:query) do 9 | %Q{ 10 | { 11 | customer(id: #{Customer.last.id}) { 12 | orders { 13 | edges { 14 | node { 15 | id 16 | } 17 | } 18 | } 19 | } 20 | } 21 | } 22 | end 23 | 24 | it 'produces the same result on miss or hit' do 25 | cold_results = execute(query) 26 | warm_results = execute(query) 27 | 28 | expect(cold_results).to eq warm_results 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/graphql/cache/deconstructor_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module GraphQL 4 | module Cache 5 | RSpec.describe Deconstructor do 6 | describe '#perform' do 7 | let(:raw) { 'foo' } 8 | let(:method) { 'object' } 9 | 10 | subject { described_class.new(raw, method) } 11 | 12 | it 'should returns the raw object' do 13 | expect(subject.perform).to eq raw 14 | end 15 | 16 | context 'when object responds to object' do 17 | let(:raw) { double('Raw', object: 'foo') } 18 | 19 | it 'should return raw.object' do 20 | expect(subject.perform).to eq raw.object 21 | end 22 | 23 | context 'when object is a Graphql::Execution::Lazy whose value responds to object' do 24 | let(:raw) { GraphQL::Execution::Lazy.new { double('Raw', object: 'foo') } } 25 | let(:method) { 'lazy' } 26 | 27 | it 'should return a Lazy that resolves to raw.object' do 28 | result = subject.perform 29 | 30 | expect(result.class).to be(GraphQL::Execution::Lazy) 31 | expect(result.value).to eq 'foo' 32 | end 33 | end 34 | end 35 | 36 | context 'when method is "array"' do 37 | let(:raw) { [1,2,3] } 38 | let(:method) { 'array' } 39 | 40 | it 'should return a flat array' do 41 | expect(subject.perform).to eq raw 42 | end 43 | 44 | context 'objects in array are GraphQL::Schema::Objects' do 45 | let(:raw) do 46 | [ 47 | CustomerType.authorized_new('foo', query.context), 48 | CustomerType.authorized_new('bar', query.context), 49 | ] 50 | end 51 | 52 | it 'should return an array of the inner objects' do 53 | expect(subject.perform).to eq ['foo', 'bar'] 54 | end 55 | end 56 | 57 | context 'at least one object is a promise', skip: !defined?(Promise) do 58 | let(:raw) do 59 | [ 60 | Promise.resolve(1), 61 | Promise.resolve(2), 62 | 3, 63 | ] 64 | end 65 | 66 | it 'should return a promise that resolves to an array of the inner objects' do 67 | result = subject.perform 68 | 69 | expect(result.class).to be(Promise) 70 | expect(result.value).to eq [1, 2, 3] 71 | end 72 | end 73 | end 74 | 75 | context 'when method is "collectionproxy"' do 76 | let(:raw) { [1,2,3] } 77 | let(:method) { 'collectionproxy' } 78 | 79 | it 'should return a flat array' do 80 | expect(subject.perform).to eq raw 81 | end 82 | 83 | context 'objects in array are GraphQL::Schema::Objects' do 84 | let(:raw) do 85 | [ 86 | CustomerType.authorized_new('foo', query.context), 87 | CustomerType.authorized_new('bar', query.context), 88 | ] 89 | end 90 | 91 | it 'should return an array of the inner objects' do 92 | expect(subject.perform).to eq ['foo', 'bar'] 93 | end 94 | end 95 | end 96 | 97 | context 'when raw is a subclass of GraphQL::Relay::BaseConnection' do 98 | let(:nodes) { [1,2,3] } 99 | let(:raw) { GraphQL::Relay::BaseConnection.new(nodes, []) } 100 | 101 | it 'should return a flat array of nodes' do 102 | expect(subject.perform).to eq [1,2,3] 103 | end 104 | 105 | context 'when raw is a GraphQL::Execution::Lazy that resolves to a subclass of Graphql::Relay::BaseConnection', skip: !defined?(Promise) do 106 | let(:raw) { GraphQL::Execution::Lazy.new { GraphQL::Relay::BaseConnection.new(nodes, []) } } 107 | let(:method) { 'lazy' } 108 | 109 | it 'should return a Lazy that resolves to a flat array of nodes' do 110 | result = subject.perform 111 | 112 | expect(result.class).to be(GraphQL::Execution::Lazy) 113 | expect(subject.perform.value).to eq [1,2,3] 114 | end 115 | end 116 | end 117 | end 118 | end 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /spec/graphql/cache/fetcher_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module GraphQL 4 | module Cache 5 | RSpec.describe Fetcher do 6 | describe 'graphql-ruby instrumentation API' do 7 | it { should respond_to :instrument } 8 | end 9 | 10 | describe '#instrument' do 11 | let(:type) { CacheSchema.types['Customer'] } 12 | let(:field) do 13 | double( 14 | 'graphql-ruby field', 15 | resolve_proc: ->(obj, args, ctx) { nil }, 16 | redefine: nil, 17 | metadata: { cache: true } 18 | ) 19 | end 20 | 21 | it 'should redefine the resolution proc' do 22 | expect(field).to receive(:redefine) 23 | subject.instrument(type, field) 24 | end 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/graphql/cache/field_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module GraphQL 4 | module Cache 5 | RSpec.describe Field do 6 | let(:cache_config) do 7 | { 8 | expiry: 10800 9 | } 10 | end 11 | 12 | subject { described_class.new(type: CustomerType, name: :title, cache: cache_config, null: true) } 13 | 14 | describe '#to_graphql' do 15 | it 'should inject cache config metadata' do 16 | expect(subject.to_graphql.metadata[:cache]).to eq cache_config 17 | end 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/graphql/cache/key_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module GraphQL 4 | module Cache 5 | RSpec.describe Key do 6 | let(:obj) { double('Inner Object', id: 123, foo: 'bar') } 7 | let(:parent_obj) { double('Parent Object', object: obj ) } 8 | let(:type) { CacheSchema.types['Customer'] } 9 | let(:field) { type.fields['orders'] } 10 | let(:args) do 11 | { 12 | foo: 'bar' 13 | } 14 | end 15 | 16 | subject { described_class.new(parent_obj, args, type, field) } 17 | 18 | describe '#to_s' do 19 | it 'should return a string' do 20 | expect(subject.to_s).to be_a String 21 | end 22 | 23 | it 'should include the global key namespace string' do 24 | target = GraphQL::Cache.namespace 25 | expect(subject.to_s[0, target.length]).to eq GraphQL::Cache.namespace 26 | end 27 | end 28 | 29 | describe '#object_clause' do 30 | it 'should return a string with class name and identifier' do 31 | expect(subject.object_clause).to eq "RSpec::Mocks::Double:#{obj.id}" 32 | end 33 | 34 | context 'parent\' inner object is nil' do 35 | let(:obj) { nil } 36 | 37 | it 'should return nil' do 38 | expect(subject.object_clause).to eq nil 39 | end 40 | end 41 | end 42 | 43 | describe '#type_clause' do 44 | it 'should return the type name' do 45 | expect(subject.type_clause).to eq type.name 46 | end 47 | end 48 | 49 | describe '#field_clause' do 50 | it 'should return the field name' do 51 | expect(subject.field_clause).to eq field.name 52 | end 53 | end 54 | 55 | describe '#arguments_clause' do 56 | it 'returns the arguments hash as a 1-dimension array' do 57 | expect(subject.arguments_clause).to eq args.to_a.flatten 58 | end 59 | end 60 | 61 | describe '#object_identifier' do 62 | context 'when metadata key is a symbol' do 63 | before do 64 | field.metadata[:cache] = { key: :foo } 65 | end 66 | 67 | it 'calls the symbol on object' do 68 | expect(obj).to receive(:foo) 69 | subject.object_identifier 70 | end 71 | end 72 | 73 | context 'when metadata key is a proc' do 74 | let(:key_proc) do 75 | -> (obj, ctx) { 'baz' } 76 | end 77 | 78 | before do 79 | field.metadata[:cache] = { key: key_proc } 80 | end 81 | 82 | it 'calls the proc passing object and query context' do 83 | expect(key_proc).to receive(:call).with(obj, {}).and_call_original 84 | subject.object_identifier 85 | end 86 | end 87 | 88 | context 'when metadata key is nil' do 89 | before do 90 | field.metadata[:cache] = { key: nil } 91 | end 92 | 93 | it 'uses guess_id' do 94 | expect(subject).to receive(:guess_id).and_call_original 95 | subject.object_identifier 96 | end 97 | end 98 | 99 | context 'when metadata key is an object' do 100 | before do 101 | field.metadata[:cache] = { key: 'foo' } 102 | end 103 | 104 | it 'returns metadata key' do 105 | expect(subject.object_identifier).to eq 'foo' 106 | end 107 | end 108 | end 109 | 110 | describe '#guess_id' do 111 | let(:obj) { 'foo' } 112 | 113 | it 'returns the object\'s object_id' do 114 | expect(subject.guess_id).to eq obj.object_id 115 | end 116 | 117 | context 'object responds to cache_key' do 118 | let(:obj) { double('Object', cache_key: 'foo') } 119 | 120 | it 'returns object.cache_key' do 121 | expect(obj).to receive(:cache_key) 122 | subject.guess_id 123 | end 124 | end 125 | 126 | context 'object responds to id' do 127 | let(:obj) { double('Object', id: 'foo') } 128 | 129 | it 'returns object.id' do 130 | expect(obj).to receive(:id) 131 | subject.guess_id 132 | end 133 | end 134 | 135 | context 'object responds to both cache_key and id' do 136 | let(:obj) { double('Object', id: 'foo', cache_key: 'bar') } 137 | 138 | it 'uses cache_key' do 139 | expect(obj).to receive(:cache_key) 140 | expect(obj).to_not receive(:id) 141 | subject.guess_id 142 | end 143 | end 144 | end 145 | end 146 | end 147 | end 148 | -------------------------------------------------------------------------------- /spec/graphql/cache/marshal_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module GraphQL 4 | module Cache 5 | RSpec.describe Marshal do 6 | let(:key) { 'key' } 7 | let(:doc) { 'value' } 8 | 9 | subject { Marshal.new(key) } 10 | 11 | describe 'Class methods' do 12 | describe '#[]' do 13 | it 'should initialize a new Marshal object' do 14 | marshal = Marshal[key] 15 | expect(marshal).to be_a Marshal 16 | expect(marshal.key).to eq key 17 | end 18 | end 19 | end 20 | 21 | describe 'helpers' do 22 | it 'should forward :cache to module' do 23 | expect(GraphQL::Cache).to receive(:cache) 24 | subject.cache 25 | end 26 | 27 | it 'should forward :logger to module' do 28 | expect(GraphQL::Cache).to receive(:logger) 29 | subject.logger 30 | end 31 | end 32 | 33 | describe '#read' do 34 | let(:config) { true } 35 | let(:block) { double('block', call: 'foo') } 36 | 37 | context 'when force is set' do 38 | it 'should execute the block' do 39 | expect(block).to receive(:call) 40 | subject.read(config, force: true) { block.call } 41 | end 42 | 43 | it 'should write to cache' do 44 | expect(cache).to receive(:write).with(key, doc, expires_in: GraphQL::Cache.expiry) 45 | subject.write(config) { doc } 46 | end 47 | end 48 | 49 | context 'when cache object exists' do 50 | before do 51 | cache.write(key, doc) 52 | end 53 | 54 | it 'should return cached value' do 55 | expect(subject.read(config) { block.call }).to eq doc 56 | end 57 | 58 | it 'should not execute the block' do 59 | expect(block).to_not receive(:call) 60 | subject.read(config) { block.call } 61 | end 62 | end 63 | 64 | context 'when cache object does not exist' do 65 | before do 66 | cache.clear 67 | end 68 | 69 | it 'should return the evaluated value' do 70 | expect(subject.read(config) { block.call }).to eq block.call 71 | end 72 | 73 | it 'should execute the block' do 74 | expect(block).to receive(:call) 75 | subject.read(config) { block.call } 76 | end 77 | end 78 | end 79 | 80 | describe '#write' do 81 | let(:config) { true } 82 | 83 | it 'should return the resolved value' do 84 | expect(subject.write(config) { doc }).to eq doc 85 | end 86 | 87 | it 'should write the object to cache' do 88 | expect(cache).to receive(:write).with(key, doc, expires_in: GraphQL::Cache.expiry) 89 | subject.write(config) { doc } 90 | end 91 | 92 | context 'when the resolved value is a promise', skip: !defined?(Promise) do 93 | let(:doc) { Promise.resolve('value') } 94 | 95 | it 'should write the promise resolution value to the cache' do 96 | expect(cache).to receive(:write).with(key, doc.value, expires_in: GraphQL::Cache.expiry) 97 | subject.write(config) { doc } 98 | end 99 | 100 | it 'should return a promise that resolves to the value' do 101 | expect((subject.write(config) { doc }).value).to eq('value') 102 | end 103 | end 104 | end 105 | 106 | describe '#expiry' do 107 | context 'when cache config is a boolean' do 108 | let(:config) { true } 109 | 110 | it 'should return global expiry' do 111 | expect(subject.expiry(config)).to eq GraphQL::Cache.expiry 112 | end 113 | end 114 | 115 | context 'when cache config is a hash' do 116 | let(:expiry) { '999' } 117 | let(:config) do 118 | { 119 | expiry: expiry 120 | } 121 | end 122 | 123 | it 'should return the config expiry' do 124 | expect(subject.expiry(config)).to eq expiry 125 | end 126 | end 127 | end 128 | end 129 | end 130 | end 131 | -------------------------------------------------------------------------------- /spec/graphql/cache_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe GraphQL::Cache do 4 | let(:cache) { GraphQL::Cache.cache } 5 | 6 | context 'configuration' do 7 | subject { described_class } 8 | 9 | it { should respond_to :cache } 10 | it { should respond_to :cache= } 11 | 12 | it { should respond_to :logger } 13 | it { should respond_to :logger= } 14 | 15 | it { should respond_to :expiry } 16 | it { should respond_to :expiry= } 17 | 18 | it { should respond_to :expiry } 19 | it { should respond_to :expiry= } 20 | 21 | 22 | it { should respond_to :namespace } 23 | it { should respond_to :namespace= } 24 | 25 | it { should respond_to :configure } 26 | 27 | describe '#configure' do 28 | it 'should yield self to allow setting config' do 29 | expect{ 30 | described_class.configure { |c| c.expiry = 1234 } 31 | }.to change{ 32 | described_class.expiry 33 | }.to 1234 34 | end 35 | end 36 | end 37 | 38 | describe '#use' do 39 | let(:schema) { double(:schema, instrument: nil) } 40 | 41 | it 'should inject the plugin' do 42 | expect(schema).to receive(:instrument).with(:field, an_instance_of(GraphQL::Cache::Fetcher)) 43 | GraphQL::Cache.use(schema) 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'bundler/setup' 2 | require 'pry' 3 | 4 | require 'simplecov' 5 | SimpleCov.start do 6 | add_filter '/spec/' 7 | end 8 | 9 | require 'graphql/cache' 10 | 11 | # Load support files 12 | Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each { |f| require f } 13 | 14 | RSpec.configure do |config| 15 | # Enable flags like --only-failures and --next-failure 16 | config.example_status_persistence_file_path = ".rspec_status" 17 | 18 | # Disable RSpec exposing methods globally on `Module` and `main` 19 | config.disable_monkey_patching! 20 | 21 | config.expect_with :rspec do |c| 22 | c.syntax = :expect 23 | end 24 | 25 | config.before(:suite) do 26 | GraphQL::Cache.cache = TestCache.new 27 | GraphQL::Cache.logger = TestLogger.new 28 | 29 | DB.logger = GraphQL::Cache.logger 30 | end 31 | 32 | # required after GraphQL::Cache initialization because dev 33 | # schema uses cache and logger objects from it. 34 | require_relative '../test_schema' 35 | 36 | config.include TestMacros 37 | config.extend TestMacros::ClassMethods 38 | end 39 | -------------------------------------------------------------------------------- /spec/support/test_cache.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class TestCache 4 | def write(key, doc, opts={}) 5 | cache[key] = doc 6 | end 7 | 8 | def read(key) 9 | cache[key] 10 | end 11 | 12 | def cache 13 | @cache ||= {} 14 | end 15 | 16 | def clear 17 | @cache = {} 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/support/test_logger.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class TestLogger 4 | %i( 5 | debug 6 | info 7 | warn 8 | error 9 | fatal 10 | unknown 11 | ).each do |lvl| 12 | define_method lvl do |msg| 13 | # msg 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/support/test_macros.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module TestMacros 4 | def cache 5 | GraphQL::Cache.cache 6 | end 7 | 8 | module ClassMethods 9 | def self.extended(mod) 10 | mod.class_eval do 11 | setup_query 12 | end 13 | end 14 | 15 | def setup_query 16 | let(:query) { GraphQL::Query.new(CacheSchema) } 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /test_schema.rb: -------------------------------------------------------------------------------- 1 | require 'logger' 2 | 3 | require_relative './test_schema/schema' 4 | require_relative './test_schema/models' 5 | require_relative './test_schema/graphql_schema' 6 | require_relative './test_schema/factories' 7 | 8 | Factories.bootstrap 9 | DB.loggers = [GraphQL::Cache.logger] 10 | -------------------------------------------------------------------------------- /test_schema/factories.rb: -------------------------------------------------------------------------------- 1 | module Factories 2 | def self.bootstrap 3 | customer = Customer.create( 4 | display_name: 'Michael', 5 | email: 'michael@example.com' 6 | ) 7 | 8 | Order.create(customer_id: customer.id, number: new_num, total_price_cents: 1399) 9 | Order.create(customer_id: customer.id, number: new_num, total_price_cents: 1399) 10 | Order.create(customer_id: customer.id, number: new_num, total_price_cents: 1399) 11 | end 12 | 13 | def self.new_num 14 | Order.count + 1000 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test_schema/graphql_schema.rb: -------------------------------------------------------------------------------- 1 | require 'benchmark' 2 | 3 | class BaseType < GraphQL::Schema::Object 4 | field_class GraphQL::Cache::Field 5 | end 6 | 7 | class OrderType < BaseType 8 | field :id, Int, null: false 9 | field :number, Int, null: true 10 | field :total_price_cents, Int, null: true 11 | end 12 | 13 | class CustomerType < BaseType 14 | field :display_name, String, null: false 15 | field :email, String, null: false 16 | field :orders, OrderType.connection_type, null: false, cache: true 17 | end 18 | 19 | class QueryType < BaseType 20 | field :customer, CustomerType, null: true, cache: true do 21 | argument :id, ID, 'Unique Identifier for querying a specific user', required: true 22 | end 23 | 24 | def customer(id:) 25 | Customer[id] 26 | end 27 | end 28 | 29 | class CacheSchema < GraphQL::Schema 30 | query QueryType 31 | 32 | use GraphQL::Cache 33 | 34 | def self.resolve_type(_type, obj, _ctx) 35 | "#{obj.class.name}Type" 36 | end 37 | 38 | def self.texecute(*args, **kwargs) 39 | result = nil 40 | measurement = Benchmark.measure { result = execute(*args, *kwargs) } 41 | GraphQL::Cache.logger.debug("Query executed in #{measurement.real}") 42 | result 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /test_schema/models.rb: -------------------------------------------------------------------------------- 1 | class Order < Sequel::Model 2 | one_to_one :customer 3 | end 4 | 5 | class Customer < Sequel::Model 6 | one_to_many :orders 7 | end 8 | -------------------------------------------------------------------------------- /test_schema/schema.rb: -------------------------------------------------------------------------------- 1 | require 'sequel' 2 | 3 | DB = Sequel.sqlite(logger: Logger.new('/dev/null')) 4 | 5 | DB.create_table :customers do 6 | primary_key :id 7 | String :display_name 8 | String :email 9 | end 10 | 11 | DB.create_table :orders do 12 | primary_key :id 13 | Integer :customer_id 14 | Integer :number 15 | Integer :total_price_cents 16 | end 17 | --------------------------------------------------------------------------------