graphql-fragment_cache 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 385b7910c87bd9fe82d32d4d1e6ac45c9d62600b2cbcc1422445c47fca70d021
4
+ data.tar.gz: a99f8ef1ffaa3026ee2f183b74abc1dea8fab099f0a2c811de1f211adf4e176d
5
+ SHA512:
6
+ metadata.gz: 3e1936a4c8d932cf022ffaa45c79d67b3217729f7b722f3209995706a9b1c6c4346c272078c1c9404707a1833b0ef786399bde0b76431eedead4ed0cb7cfcfa0
7
+ data.tar.gz: 2a550ee8ba377ec9d199266fb75bdd9fe51bb46595c8839d5d0a9a43f9c3874b897d73b718fb66b4f2891721dbac56a436d05f66ad197f5ca31a973f6c592b41
@@ -0,0 +1,11 @@
1
+ # Change log
2
+
3
+ ## master
4
+
5
+ ## 0.1.0 (2020-04-14)
6
+
7
+ - Initial version ([@DmitryTsepelev][], [@palkan][], [@ssnickolay][])
8
+
9
+ [@DmitryTsepelev]: https://github.com/DmitryTsepelev
10
+ [@palkan]: https://github.com/palkan
11
+ [@ssnickolay]: https://github.com/ssnickolay
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2020 DmitryTsepelev
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.
@@ -0,0 +1,283 @@
1
+ # GraphQL::FragmentCache ![CI](https://github.com/DmitryTsepelev/graphql-ruby-fragment_cache/workflows/CI/badge.svg?branch=master)
2
+
3
+ `GraphQL::FragmentCache` powers up [graphql-ruby](https://graphql-ruby.org) 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:
4
+
5
+ ```ruby
6
+ class PostType < BaseObject
7
+ field :id, ID, null: false
8
+ field :title, String, null: false, cache_fragment: true
9
+ end
10
+ ```
11
+
12
+ <p align="center">
13
+ <a href="https://evilmartians.com/?utm_source=graphql-ruby-fragment_cache">
14
+ <img src="https://evilmartians.com/badges/sponsored-by-evil-martians.svg" alt="Sponsored by Evil Martians" width="236" height="54">
15
+ </a>
16
+ </p>
17
+
18
+ ## Getting started
19
+
20
+ 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!):
21
+
22
+ ```ruby
23
+ class GraphqSchema < GraphQL::Schema
24
+ use GraphQL::Execution::Interpreter
25
+ use GraphQL::Analysis::AST
26
+
27
+ use GraphQL::FragmentCache
28
+
29
+ query QueryType
30
+ end
31
+ ```
32
+
33
+ Include `GraphQL::FragmentCache::Object` to your base type class:
34
+
35
+ ```ruby
36
+ class BaseType < GraphQL::Schema::Object
37
+ include GraphQL::FragmentCache::Object
38
+ end
39
+ ```
40
+
41
+ Now you can add `cache_fragment:` option to your fields to turn caching on:
42
+
43
+ ```ruby
44
+ class PostType < BaseObject
45
+ field :id, ID, null: false
46
+ field :title, String, null: false, cache_fragment: true
47
+ end
48
+ ```
49
+
50
+ Alternatively, you can use `cache_fragment` method inside resolvers:
51
+
52
+ ```ruby
53
+ class QueryType < BaseObject
54
+ field :post, PostType, null: true do
55
+ argument :id, ID, required: true
56
+ end
57
+
58
+ def post(id:)
59
+ cache_fragment { Post.find(id) }
60
+ end
61
+ end
62
+ ```
63
+
64
+ ## Cache key generation
65
+
66
+ Cache keys consist of implicit and explicit (provided by user) parts.
67
+
68
+ ### Implicit cache key
69
+
70
+ Implicit part of a cache key (its prefix) contains the information about the schema and the current query. It includes:
71
+
72
+ - Hex gsdigest of the schema definition (to make sure cache is cleared when the schema changes).
73
+ - The current query fingerprint consisting of a _path_ to the field, arguments information and the selections set.
74
+
75
+ Let's take a look at the example:
76
+
77
+ ```ruby
78
+ query = <<~GQL
79
+ query {
80
+ post(id: 1) {
81
+ id
82
+ title
83
+ cachedAuthor {
84
+ id
85
+ name
86
+ }
87
+ }
88
+ }
89
+ GQL
90
+
91
+ schema_cache_key = GraphqSchema.schema_cache_key
92
+
93
+ path_cache_key = "post(id:1)/cachedAuthor"
94
+ selections_cache_key = "[#{%w[id name].join(".")}]"
95
+
96
+ query_cache_key = Digest::SHA1.hexdigest("#{path_cache_key}#{selections_cache_key}")
97
+
98
+ cache_key = "#{schema_cache_key}/#{query_cache_key}"
99
+ ```
100
+
101
+ You can override `schema_cache_key` or `query_cache_key` by passing parameters to the `cache_fragment` calls:
102
+
103
+ ```ruby
104
+ class QueryType < BaseObject
105
+ field :post, PostType, null: true do
106
+ argument :id, ID, required: true
107
+ end
108
+
109
+ def post(id:)
110
+ cache_fragment(query_cache_key: "post(#{id})") { Post.find(id) }
111
+ end
112
+ end
113
+ ```
114
+
115
+ Same for the option:
116
+
117
+ ```ruby
118
+ class PostType < BaseObject
119
+ field :id, ID, null: false
120
+ field :title, String, null: false, cache_fragment: {query_cache_key: "post_title"}
121
+ end
122
+ ```
123
+
124
+ ### User-provided cache key
125
+
126
+ 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](https://guides.rubyonrails.org/caching_with_rails.html#fragment-caching):
127
+
128
+ ```ruby
129
+ def post(id:)
130
+ post = Post.find(id)
131
+ cache_fragment(post) { post }
132
+ end
133
+ ```
134
+
135
+ You can pass arrays as well to build a compound cache key:
136
+
137
+ ```ruby
138
+ def post(id:)
139
+ post = Post.find(id)
140
+ cache_fragment([post, current_account]) { post }
141
+ end
142
+ ```
143
+
144
+ You can omit the block if its return value is the same as the cached object:
145
+
146
+ ```ruby
147
+ # the following line
148
+ cache_fragment(post)
149
+ # is the same as
150
+ cache_fragment(post) { post }
151
+ ```
152
+
153
+ When using `cache_fragment:` option, it's only possible to use the resolved value as a cache key by setting:
154
+
155
+ ```ruby
156
+ field :post, PostType, null: true, cache_fragment: {cache_key: :object} do
157
+ argument :id, ID, required: true
158
+ end
159
+
160
+ # this is equal to
161
+ def post(id:)
162
+ cache_fragment(Post.find(id))
163
+ end
164
+ ```
165
+
166
+ Also, you can pass `:value` to the `cache_key:` argument to use the returned value to build a key:
167
+
168
+ ```ruby
169
+ field :post, PostType, null: true, cache_fragment: {cache_key: :value} do
170
+ argument :id, ID, required: true
171
+ end
172
+
173
+ # this is equal to
174
+ def post(id:)
175
+ post = Post.find(id)
176
+ cache_fragment(post) { post }
177
+ end
178
+ ```
179
+
180
+ The way cache key part is generated for the passed argument is the following:
181
+
182
+ - Use `#graphql_cache_key` if implemented.
183
+ - Use `#cache_key` (or `#cache_key_with_version` for modern Rails) if implemented.
184
+ - Use `self.to_s` for _primitive_ types (strings, symbols, numbers, booleans).
185
+ - Raise `ArgumentError` if none of the above.
186
+
187
+ ### Context cache key
188
+
189
+ 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_.
190
+
191
+ 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:`.
192
+
193
+ For instance, imagine a query that allows the current user's social profiles:
194
+
195
+ ```gql
196
+ query {
197
+ socialProfiles {
198
+ provider
199
+ id
200
+ }
201
+ }
202
+ ```
203
+
204
+ You can cache the result using the context (`context[:user]`) as a cache key:
205
+
206
+ ```ruby
207
+ class QueryType < BaseObject
208
+ field :social_profiles, [SocialProfileType], null: false, cache_fragment: {context_key: :user}
209
+
210
+ def social_profiles
211
+ context[:user].social_profiles
212
+ end
213
+ end
214
+ ```
215
+
216
+ This is equal to using `#cache_fragment` the following way:
217
+
218
+ ```ruby
219
+ class QueryType < BaseObject
220
+ field :social_profiles, [SocialProfileType], null: false
221
+
222
+ def social_profiles
223
+ cache_fragment(context[:user]) { context[:user].social_profiles }
224
+ end
225
+ end
226
+ ```
227
+
228
+ ## Cache storage and options
229
+
230
+ It's up to your to decide which caching engine to use, all you need is to configure the cache store:
231
+
232
+ ```ruby
233
+ GraphQL::FragmentCache.cache_store = MyCacheStore.new
234
+ ```
235
+
236
+ Or, in Rails:
237
+
238
+ ```ruby
239
+ # config/application.rb (or config/environments/<environment>.rb)
240
+ Rails.application.configure do |config|
241
+ # arguments and options are the same as for `config.cache_store`
242
+ config.graphql_fragment_cache.store = :redis_cache_store
243
+ end
244
+ ```
245
+
246
+ ⚠️ Cache store must implement `#read(key)` and `#write(key, value, **options)` methods.
247
+
248
+ The gem provides only in-memory store out-of-the-box (`GraphQL::FragmentCache::MemoryStore`). It's used by default.
249
+
250
+ 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):
251
+
252
+ ```ruby
253
+ class PostType < BaseObject
254
+ field :id, ID, null: false
255
+ field :title, String, null: false, cache_fragment: {expires_in: 5.minutes}
256
+ end
257
+
258
+ class QueryType < BaseObject
259
+ field :post, PostType, null: true do
260
+ argument :id, ID, required: true
261
+ end
262
+
263
+ def post(id:)
264
+ cache_fragment(expires_in: 5.minutes) { Post.find(id) }
265
+ end
266
+ end
267
+ ```
268
+
269
+ ## Limitations
270
+
271
+ - [Field aliases](https://spec.graphql.org/June2018/#sec-Field-Alias) are not currently supported (take a look at the failing spec [here](https://github.com/DmitryTsepelev/graphql-ruby-fragment_cache/pull/7))
272
+
273
+ ## Credits
274
+
275
+ Based on the original [gist](https://gist.github.com/palkan/faad9f6ff1db16fcdb1c071ec50e4190) by [@palkan](https://github.com/palkan) and [@ssnickolay](https://github.com/ssnickolay).
276
+
277
+ ## Contributing
278
+
279
+ Bug reports and pull requests are welcome on GitHub at [https://github.com/DmitryTsepelev/graphql-ruby-fragment_cache](https://github.com/DmitryTsepelev/graphql-ruby-fragment_cache).
280
+
281
+ ## License
282
+
283
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "graphql/ruby/fragment_cache"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start(__FILE__)
@@ -0,0 +1,8 @@
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
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "digest"
5
+
6
+ using RubyNext
7
+
8
+ module GraphQL
9
+ module FragmentCache
10
+ using Ext
11
+
12
+ using(Module.new {
13
+ refine Array do
14
+ def to_selections_key
15
+ map { |val|
16
+ children = val.selections.empty? ? "" : "[#{val.selections.to_selections_key}]"
17
+ "#{val.field.name}#{children}"
18
+ }.join(".")
19
+ end
20
+ end
21
+ })
22
+
23
+ # Builds cache key for fragment
24
+ class CacheKeyBuilder
25
+ using RubyNext
26
+
27
+ class << self
28
+ def call(**options)
29
+ new(**options).build
30
+ end
31
+ end
32
+
33
+ attr_reader :query, :path, :object, :schema
34
+
35
+ def initialize(object: nil, query:, path:, **options)
36
+ @object = object
37
+ @query = query
38
+ @schema = query.schema
39
+ @path = path
40
+ @options = options
41
+ end
42
+
43
+ def build
44
+ Digest::SHA1.hexdigest("#{schema_cache_key}/#{query_cache_key}").then do |base_key|
45
+ next base_key unless object
46
+ "#{base_key}/#{object_key(object)}"
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def schema_cache_key
53
+ @options.fetch(:schema_cache_key, schema.schema_cache_key)
54
+ end
55
+
56
+ def query_cache_key
57
+ @options.fetch(:query_cache_key, "#{path_cache_key}[#{selections_cache_key}]")
58
+ end
59
+
60
+ def selections_cache_key
61
+ current_root =
62
+ path.reduce(query.lookahead) { |lkhd, name| lkhd.selection(name) }
63
+
64
+ current_root.selections.to_selections_key
65
+ end
66
+
67
+ def path_cache_key
68
+ lookahead = query.lookahead
69
+
70
+ path.map { |field_name|
71
+ lookahead = lookahead.selection(field_name)
72
+
73
+ next field_name if lookahead.arguments.empty?
74
+
75
+ args = lookahead.arguments.map { |_1, _2| "#{_1}:#{_2}" }.sort.join(",")
76
+ "#{field_name}(#{args})"
77
+ }.join("/")
78
+ end
79
+
80
+ def object_key(obj)
81
+ obj._graphql_cache_key
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module FragmentCache
5
+ using Ext
6
+
7
+ # Saves resolved fragment values to cache store
8
+ module Cacher
9
+ class << self
10
+ def call(query)
11
+ return unless query.context.fragments?
12
+
13
+ final_value = query.context.namespace(:interpreter)[:runtime].final_value
14
+
15
+ query.context.fragments.each { |_1| _1.persist(final_value) }
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module FragmentCache
5
+ module Ext
6
+ # Adds #_graphql_cache_key method to Object,
7
+ # which just call #graphql_cache_key or #cache_key.
8
+ #
9
+ # For other core classes returns string representation.
10
+ #
11
+ # Raises ArgumentError otherwise.
12
+ #
13
+ # We use a refinement to avoid case/if statements for type checking
14
+ refine Object do
15
+ def _graphql_cache_key
16
+ return graphql_cache_key if respond_to?(:graphql_cache_key)
17
+ return cache_key if respond_to?(:cache_key)
18
+ return to_a._graphql_cache_key if respond_to?(:to_a)
19
+
20
+ to_s
21
+ end
22
+ end
23
+
24
+ refine Array do
25
+ def _graphql_cache_key
26
+ map { |_1| _1._graphql_cache_key }.join("/")
27
+ end
28
+ end
29
+
30
+ refine NilClass do
31
+ def _graphql_cache_key
32
+ ""
33
+ end
34
+ end
35
+
36
+ refine TrueClass do
37
+ def _graphql_cache_key
38
+ "t"
39
+ end
40
+ end
41
+
42
+ refine FalseClass do
43
+ def _graphql_cache_key
44
+ "f"
45
+ end
46
+ end
47
+
48
+ refine String do
49
+ def _graphql_cache_key
50
+ self
51
+ end
52
+ end
53
+
54
+ refine Symbol do
55
+ def _graphql_cache_key
56
+ to_s
57
+ end
58
+ end
59
+
60
+ if RUBY_PLATFORM.match?(/java/i)
61
+ refine Integer do
62
+ def _graphql_cache_key
63
+ to_s
64
+ end
65
+ end
66
+
67
+ refine Float do
68
+ def _graphql_cache_key
69
+ to_s
70
+ end
71
+ end
72
+ else
73
+ refine Numeric do
74
+ def _graphql_cache_key
75
+ to_s
76
+ end
77
+ end
78
+ end
79
+
80
+ refine Time do
81
+ def _graphql_cache_key
82
+ to_s
83
+ end
84
+ end
85
+
86
+ refine Module do
87
+ def _graphql_cache_key
88
+ name
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,6 @@
1
+ require "ruby-next"
2
+
3
+ require "ruby-next/language/setup"
4
+ RubyNext::Language.setup_gem_load_path
5
+
6
+ require "graphql/fragment_cache"
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "graphql"
4
+
5
+ require "graphql/fragment_cache/ext/context_fragments"
6
+ require "graphql/fragment_cache/ext/graphql_cache_key"
7
+
8
+ require "graphql/fragment_cache/schema_patch"
9
+ require "graphql/fragment_cache/object"
10
+ require "graphql/fragment_cache/instrumentation"
11
+
12
+ require "graphql/fragment_cache/memory_store"
13
+
14
+ require "graphql/fragment_cache/version"
15
+ require "graphql/fragment_cache/railtie" if defined?(Rails::Railtie)
16
+
17
+ module GraphQL
18
+ # Plugin definition
19
+ module FragmentCache
20
+ class << self
21
+ attr_reader :cache_store
22
+
23
+ def use(schema_defn, options = {})
24
+ verify_interpreter!(schema_defn)
25
+
26
+ schema_defn.instrument(:query, Instrumentation)
27
+ schema_defn.extend(SchemaPatch)
28
+ end
29
+
30
+ def cache_store=(store)
31
+ unless store.respond_to?(:read)
32
+ raise ArgumentError, "Store must implement #read(key) method"
33
+ end
34
+
35
+ unless store.respond_to?(:write)
36
+ raise ArgumentError, "Store must implement #write(key, val, **options) method"
37
+ end
38
+
39
+ @cache_store = store
40
+ end
41
+
42
+ private
43
+
44
+ def verify_interpreter!(schema_defn)
45
+ unless schema_defn.interpreter?
46
+ raise StandardError,
47
+ "GraphQL::Execution::Interpreter should be enabled for fragment caching"
48
+ end
49
+
50
+ unless schema_defn.analysis_engine == GraphQL::Analysis::AST
51
+ raise StandardError,
52
+ "GraphQL::Analysis::AST should be enabled for fragment caching"
53
+ end
54
+ end
55
+ end
56
+
57
+ self.cache_store = MemoryStore.new
58
+ end
59
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "digest"
5
+
6
+ using RubyNext
7
+
8
+ module GraphQL
9
+ module FragmentCache
10
+ using Ext
11
+
12
+ using(Module.new {
13
+ refine Array do
14
+ def to_selections_key
15
+ map { |val|
16
+ children = val.selections.empty? ? "" : "[#{val.selections.to_selections_key}]"
17
+ "#{val.field.name}#{children}"
18
+ }.join(".")
19
+ end
20
+ end
21
+ })
22
+
23
+ # Builds cache key for fragment
24
+ class CacheKeyBuilder
25
+ using RubyNext
26
+
27
+ class << self
28
+ def call(**options)
29
+ new(**options).build
30
+ end
31
+ end
32
+
33
+ attr_reader :query, :path, :object, :schema
34
+
35
+ def initialize(object: nil, query:, path:, **options)
36
+ @object = object
37
+ @query = query
38
+ @schema = query.schema
39
+ @path = path
40
+ @options = options
41
+ end
42
+
43
+ def build
44
+ Digest::SHA1.hexdigest("#{schema_cache_key}/#{query_cache_key}").then do |base_key|
45
+ next base_key unless object
46
+ "#{base_key}/#{object_key(object)}"
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def schema_cache_key
53
+ @options.fetch(:schema_cache_key, schema.schema_cache_key)
54
+ end
55
+
56
+ def query_cache_key
57
+ @options.fetch(:query_cache_key, "#{path_cache_key}[#{selections_cache_key}]")
58
+ end
59
+
60
+ def selections_cache_key
61
+ current_root =
62
+ path.reduce(query.lookahead) { |lkhd, name| lkhd.selection(name) }
63
+
64
+ current_root.selections.to_selections_key
65
+ end
66
+
67
+ def path_cache_key
68
+ lookahead = query.lookahead
69
+
70
+ path.map { |field_name|
71
+ lookahead = lookahead.selection(field_name)
72
+
73
+ next field_name if lookahead.arguments.empty?
74
+
75
+ args = lookahead.arguments.map { "#{_1}:#{_2}" }.sort.join(",")
76
+ "#{field_name}(#{args})"
77
+ }.join("/")
78
+ end
79
+
80
+ def object_key(obj)
81
+ obj._graphql_cache_key
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module FragmentCache
5
+ using Ext
6
+
7
+ # Saves resolved fragment values to cache store
8
+ module Cacher
9
+ class << self
10
+ def call(query)
11
+ return unless query.context.fragments?
12
+
13
+ final_value = query.context.namespace(:interpreter)[:runtime].final_value
14
+
15
+ query.context.fragments.each { _1.persist(final_value) }
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module FragmentCache
5
+ module Ext
6
+ # Add ability to access fragments via `context.fragments`
7
+ # without dupclicating the storage logic and monkey-patching
8
+ refine GraphQL::Query::Context do
9
+ def fragments?
10
+ namespace(:fragment_cache)[:fragments]
11
+ end
12
+
13
+ def fragments
14
+ namespace(:fragment_cache)[:fragments] ||= []
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module FragmentCache
5
+ module Ext
6
+ # Adds #_graphql_cache_key method to Object,
7
+ # which just call #graphql_cache_key or #cache_key.
8
+ #
9
+ # For other core classes returns string representation.
10
+ #
11
+ # Raises ArgumentError otherwise.
12
+ #
13
+ # We use a refinement to avoid case/if statements for type checking
14
+ refine Object do
15
+ def _graphql_cache_key
16
+ return graphql_cache_key if respond_to?(:graphql_cache_key)
17
+ return cache_key if respond_to?(:cache_key)
18
+ return to_a._graphql_cache_key if respond_to?(:to_a)
19
+
20
+ to_s
21
+ end
22
+ end
23
+
24
+ refine Array do
25
+ def _graphql_cache_key
26
+ map { _1._graphql_cache_key }.join("/")
27
+ end
28
+ end
29
+
30
+ refine NilClass do
31
+ def _graphql_cache_key
32
+ ""
33
+ end
34
+ end
35
+
36
+ refine TrueClass do
37
+ def _graphql_cache_key
38
+ "t"
39
+ end
40
+ end
41
+
42
+ refine FalseClass do
43
+ def _graphql_cache_key
44
+ "f"
45
+ end
46
+ end
47
+
48
+ refine String do
49
+ def _graphql_cache_key
50
+ self
51
+ end
52
+ end
53
+
54
+ refine Symbol do
55
+ def _graphql_cache_key
56
+ to_s
57
+ end
58
+ end
59
+
60
+ if RUBY_PLATFORM.match?(/java/i)
61
+ refine Integer do
62
+ def _graphql_cache_key
63
+ to_s
64
+ end
65
+ end
66
+
67
+ refine Float do
68
+ def _graphql_cache_key
69
+ to_s
70
+ end
71
+ end
72
+ else
73
+ refine Numeric do
74
+ def _graphql_cache_key
75
+ to_s
76
+ end
77
+ end
78
+ end
79
+
80
+ refine Time do
81
+ def _graphql_cache_key
82
+ to_s
83
+ end
84
+ end
85
+
86
+ refine Module do
87
+ def _graphql_cache_key
88
+ name
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module FragmentCache
5
+ # Wraps resolver with cache method
6
+ class FieldExtension < GraphQL::Schema::FieldExtension
7
+ module Patch
8
+ def initialize(*args, **kwargs, &block)
9
+ cache_fragment = kwargs.delete(:cache_fragment)
10
+
11
+ if cache_fragment
12
+ kwargs[:extensions] ||= []
13
+ kwargs[:extensions] << build_extension(cache_fragment)
14
+ end
15
+
16
+ super
17
+ end
18
+
19
+ private
20
+
21
+ def build_extension(options)
22
+ if options.is_a?(Hash)
23
+ {FieldExtension => options}
24
+ else
25
+ FieldExtension
26
+ end
27
+ end
28
+ end
29
+
30
+ def initialize(options:, **_rest)
31
+ @cache_options = options || {}
32
+
33
+ @context_key = @cache_options.delete(:context_key)
34
+ @cache_key = @cache_options.delete(:cache_key)
35
+ end
36
+
37
+ def resolve(object:, arguments:, **_options)
38
+ resolved_value = yield(object, arguments)
39
+
40
+ object_for_key = if @context_key
41
+ Array(@context_key).map { |key| object.context[key] }
42
+ elsif @cache_key == :object
43
+ object.object
44
+ elsif @cache_key == :value
45
+ resolved_value
46
+ end
47
+
48
+ cache_fragment_options = @cache_options.merge(object: object_for_key)
49
+
50
+ object.cache_fragment(cache_fragment_options) { resolved_value }
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "graphql/fragment_cache/cache_key_builder"
4
+
5
+ module GraphQL
6
+ module FragmentCache
7
+ # Represents a single fragment to cache
8
+ class Fragment
9
+ attr_reader :options, :path, :context
10
+
11
+ def initialize(context, **options)
12
+ @context = context
13
+ @options = options
14
+ @path = context.namespace(:interpreter)[:current_path]
15
+ end
16
+
17
+ def read
18
+ FragmentCache.cache_store.read(cache_key)
19
+ end
20
+
21
+ def persist(final_value)
22
+ value = resolve(final_value)
23
+ FragmentCache.cache_store.write(cache_key, value, **options)
24
+ end
25
+
26
+ private
27
+
28
+ def cache_key
29
+ @cache_key ||= CacheKeyBuilder.call(path: path, query: context.query, **options)
30
+ end
31
+
32
+ def resolve(final_value)
33
+ final_value.dig(*path)
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "graphql/fragment_cache/cacher"
4
+
5
+ module GraphQL
6
+ module FragmentCache
7
+ # Adds hook for saving cached values after query is resolved
8
+ module Instrumentation
9
+ module_function
10
+
11
+ def before_query(query)
12
+ end
13
+
14
+ def after_query(query)
15
+ return unless query.valid?
16
+
17
+ Cacher.call(query)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ using RubyNext
4
+
5
+ module GraphQL
6
+ module FragmentCache
7
+ # Memory adapter for storing cached fragments
8
+ class MemoryStore
9
+ using RubyNext
10
+
11
+ class Entry < Struct.new(:value, :expires_at, keyword_init: true)
12
+ def expired?
13
+ expires_at && expires_at < Time.now
14
+ end
15
+ end
16
+
17
+ attr_reader :default_expires_in
18
+
19
+ def initialize(expires_in: nil, **other)
20
+ raise ArgumentError, "Unsupported options: #{other.keys.join(",")}" unless other.empty?
21
+
22
+ @default_expires_in = expires_in
23
+ @storage = {}
24
+ end
25
+
26
+ def read(key)
27
+ key = key.to_s
28
+ storage[key]&.then do |entry|
29
+ if entry.expired?
30
+ delete(key)
31
+ next
32
+ end
33
+ entry.value
34
+ end
35
+ end
36
+
37
+ def write(key, value, expires_in: default_expires_in, **options)
38
+ key = key.to_s
39
+ @storage[key] = Entry.new(value: value, expires_at: expires_in ? Time.now + expires_in : nil)
40
+ end
41
+
42
+ def delete(key)
43
+ key = key.to_s
44
+ storage.delete(key)
45
+ end
46
+
47
+ def clear
48
+ storage.clear
49
+ end
50
+
51
+ private
52
+
53
+ attr_reader :storage
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "graphql/fragment_cache/object_helpers"
4
+ require "graphql/fragment_cache/field_extension"
5
+
6
+ module GraphQL
7
+ module FragmentCache
8
+ # Adds #cache_fragment method and kwarg option
9
+ module Object
10
+ def self.included(base)
11
+ base.include(GraphQL::FragmentCache::ObjectHelpers)
12
+ base.field_class.prepend(GraphQL::FragmentCache::FieldExtension::Patch)
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "graphql/fragment_cache/fragment"
4
+
5
+ module GraphQL
6
+ module FragmentCache
7
+ using Ext
8
+
9
+ # Adds #cache_fragment method
10
+ module ObjectHelpers
11
+ def cache_fragment(object_to_cache = nil, **options, &block)
12
+ fragment = Fragment.new(context, options)
13
+
14
+ if (cached = fragment.read)
15
+ return raw_value(cached)
16
+ end
17
+
18
+ context.fragments << fragment
19
+
20
+ object_to_cache || block.call
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module FragmentCache
5
+ # Extends key builder to use .expand_cache_key in Rails
6
+ class CacheKeyBuilder
7
+ def object_key(obj)
8
+ return obj.graphql_cache_key if obj.respond_to?(:graphql_cache_key)
9
+ return obj.map { |item| object_key(item) }.join("/") if obj.is_a?(Array)
10
+ return object_key(obj.to_a) if obj.respond_to?(:to_a)
11
+
12
+ ActiveSupport::Cache.expand_cache_key(obj)
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "graphql/fragment_cache/rails/cache_key_builder"
4
+
5
+ module GraphQL
6
+ module FragmentCache
7
+ class Railtie < ::Rails::Railtie # :nodoc:
8
+ # Provides Rails-specific configuration,
9
+ # accessible through `Rails.application.config.graphql_fragment_cache`
10
+ module Config
11
+ class << self
12
+ def store=(store)
13
+ # Handle both:
14
+ # store = :memory
15
+ # store = :mem_cache, ENV['MEMCACHE']
16
+ if store.is_a?(Symbol) || store.is_a?(Array)
17
+ store = ActiveSupport::Cache.lookup_store(store)
18
+ end
19
+
20
+ FragmentCache.cache_store = store
21
+ end
22
+ end
23
+ end
24
+
25
+ config.graphql_fragment_cache = Config
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest/sha1"
4
+
5
+ module GraphQL
6
+ module FragmentCache
7
+ # Patches GraphQL::Schema to support fragment cache
8
+ module SchemaPatch
9
+ def schema_cache_key
10
+ @schema_cache_key ||= Digest::SHA1.hexdigest(to_definition)
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module FragmentCache
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
metadata ADDED
@@ -0,0 +1,168 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: graphql-fragment_cache
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - DmitryTsepelev
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2020-04-14 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: graphql
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 1.10.3
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: 1.10.3
27
+ - !ruby/object:Gem::Dependency
28
+ name: ruby-next-core
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: 0.5.1
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: 0.5.1
41
+ - !ruby/object:Gem::Dependency
42
+ name: combustion
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.1'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.1'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rake
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '13.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '13.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rspec
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '3.9'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '3.9'
83
+ - !ruby/object:Gem::Dependency
84
+ name: timecop
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: ruby-next
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0.5'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0.5'
111
+ description: Fragment cache for graphql-ruby
112
+ email:
113
+ - dmitry.a.tsepelev@gmail.com
114
+ executables: []
115
+ extensions: []
116
+ extra_rdoc_files: []
117
+ files:
118
+ - CHANGELOG.md
119
+ - LICENSE.txt
120
+ - README.md
121
+ - bin/console
122
+ - bin/setup
123
+ - lib/.rbnext/2.7/graphql/fragment_cache/cache_key_builder.rb
124
+ - lib/.rbnext/2.7/graphql/fragment_cache/cacher.rb
125
+ - lib/.rbnext/2.7/graphql/fragment_cache/ext/graphql_cache_key.rb
126
+ - lib/graphql-fragment_cache.rb
127
+ - lib/graphql/fragment_cache.rb
128
+ - lib/graphql/fragment_cache/cache_key_builder.rb
129
+ - lib/graphql/fragment_cache/cacher.rb
130
+ - lib/graphql/fragment_cache/ext/context_fragments.rb
131
+ - lib/graphql/fragment_cache/ext/graphql_cache_key.rb
132
+ - lib/graphql/fragment_cache/field_extension.rb
133
+ - lib/graphql/fragment_cache/fragment.rb
134
+ - lib/graphql/fragment_cache/instrumentation.rb
135
+ - lib/graphql/fragment_cache/memory_store.rb
136
+ - lib/graphql/fragment_cache/object.rb
137
+ - lib/graphql/fragment_cache/object_helpers.rb
138
+ - lib/graphql/fragment_cache/rails/cache_key_builder.rb
139
+ - lib/graphql/fragment_cache/railtie.rb
140
+ - lib/graphql/fragment_cache/schema_patch.rb
141
+ - lib/graphql/fragment_cache/version.rb
142
+ homepage: https://github.com/DmitryTsepelev/graphql-ruby-fragment_cache
143
+ licenses:
144
+ - MIT
145
+ metadata:
146
+ homepage_uri: https://github.com/DmitryTsepelev/graphql-ruby-fragment_cache
147
+ source_code_uri: https://github.com/DmitryTsepelev/graphql-ruby-fragment_cache
148
+ changelog_uri: https://github.com/DmitryTsepelev/graphql-ruby-fragment_cache/CHANGELOG.md
149
+ post_install_message:
150
+ rdoc_options: []
151
+ require_paths:
152
+ - lib
153
+ required_ruby_version: !ruby/object:Gem::Requirement
154
+ requirements:
155
+ - - ">="
156
+ - !ruby/object:Gem::Version
157
+ version: '2.5'
158
+ required_rubygems_version: !ruby/object:Gem::Requirement
159
+ requirements:
160
+ - - ">="
161
+ - !ruby/object:Gem::Version
162
+ version: '0'
163
+ requirements: []
164
+ rubygems_version: 3.0.3
165
+ signing_key:
166
+ specification_version: 4
167
+ summary: Fragment cache for graphql-ruby
168
+ test_files: []