From 1d7c8eb8224f6746d345e6df9aba5073d69d5a2d Mon Sep 17 00:00:00 2001 From: Martin Gruner Date: Fri, 14 Feb 2025 12:03:52 +0100 Subject: [PATCH 1/4] Add possibility to include and exclude arguments from generated cache key --- .../fragment_cache/cache_key_builder.rb | 10 +++++-- .../fragment_cache/cache_key_builder_spec.rb | 26 ++++++++++++++++++- spec/graphql/fragment_cache/cacher_spec.rb | 24 +++++++++++++++++ 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/lib/graphql/fragment_cache/cache_key_builder.rb b/lib/graphql/fragment_cache/cache_key_builder.rb index f5aa5ca..6a2e53a 100644 --- a/lib/graphql/fragment_cache/cache_key_builder.rb +++ b/lib/graphql/fragment_cache/cache_key_builder.rb @@ -130,16 +130,22 @@ def path_cache_key next lookahead.field.name if lookahead.arguments.empty? - args = lookahead.arguments.map { "#{_1}:#{traverse_argument(_2)}" }.sort.join(",") + args = lookahead.arguments.select { include_argument?(_1) }.map { "#{_1}:#{traverse_argument(_2)}" }.sort.join(",") "#{lookahead.field.name}(#{args})" }.join("/") end end + def include_argument?(argument_name) + return false if @options[:exclude_arguments]&.include?(argument_name) + return false if @options[:include_arguments] && !@options[:include_arguments].include?(argument_name) + true + end + def traverse_argument(argument) return argument unless argument.is_a?(GraphQL::Schema::InputObject) - "{#{argument.map { "#{_1}:#{traverse_argument(_2)}" }.sort.join(",")}}" + "{#{argument.map { include_argument?(_1) ? "#{_1}:#{traverse_argument(_2)}" : nil }.compact.sort.join(",")}}" end def object_cache_key diff --git a/spec/graphql/fragment_cache/cache_key_builder_spec.rb b/spec/graphql/fragment_cache/cache_key_builder_spec.rb index 6c695e2..9cf79c3 100644 --- a/spec/graphql/fragment_cache/cache_key_builder_spec.rb +++ b/spec/graphql/fragment_cache/cache_key_builder_spec.rb @@ -66,6 +66,18 @@ end specify { is_expected.to eq "graphql/cachedPost/schema_key-cachedPost(id:#{id})[id.title.author[id.name]]" } + + context "when excluding arguments" do + let(:options) { {exclude_arguments: [:id]} } + + specify { is_expected.to eq "graphql/cachedPost/schema_key-cachedPost()[id.title.author[id.name]]" } + end + + context "when including arguments" do + let(:options) { {include_arguments: [:id]} } + + specify { is_expected.to eq "graphql/cachedPost/schema_key-cachedPost(id:#{id})[id.title.author[id.name]]" } + end end context "when cached field has aliased selections" do @@ -109,7 +121,7 @@ specify { is_expected.to eq "graphql/cachedPostByInput/schema_key-cachedPostByInput(input_with_id:{id:#{id},int_arg:42})[id.title.author[id.name]]" } - context "when argument is complext input" do + context "when argument is complex input" do let(:query) do <<~GQL query GetPostByComplexInput($complexPostInput: ComplexPostInput!) { @@ -130,6 +142,18 @@ let(:variables) { {complexPostInput: {stringArg: "woo", inputWithId: {id: id, intArg: 42}}} } specify { is_expected.to eq "graphql/cachedPostByComplexInput/schema_key-cachedPostByComplexInput(complex_post_input:{input_with_id:{id:#{id},int_arg:42},string_arg:woo})[id.title.author[id.name]]" } + + context "when excluding arguments" do + let(:options) { {exclude_arguments: [:int_arg]} } + + specify { is_expected.to eq "graphql/cachedPostByComplexInput/schema_key-cachedPostByComplexInput(complex_post_input:{input_with_id:{id:#{id}},string_arg:woo})[id.title.author[id.name]]" } + end + + context "when including arguments" do + let(:options) { {include_arguments: [:complex_post_input, :input_with_id, :int_arg]} } + + specify { is_expected.to eq "graphql/cachedPostByComplexInput/schema_key-cachedPostByComplexInput(complex_post_input:{input_with_id:{int_arg:42}})[id.title.author[id.name]]" } + end end end diff --git a/spec/graphql/fragment_cache/cacher_spec.rb b/spec/graphql/fragment_cache/cacher_spec.rb index a946b6d..197aae7 100644 --- a/spec/graphql/fragment_cache/cacher_spec.rb +++ b/spec/graphql/fragment_cache/cacher_spec.rb @@ -118,6 +118,30 @@ def write_multi(hash, options) expect(args).to eq([{query_cache_key: "1"}, {query_cache_key: "2"}]) end + + context "when different options exist, but should be excluded" do + let(:schema) do + build_schema do + query( + Class.new(Types::Query) { + field :post, Types::Post, null: true do + argument :id, GraphQL::Types::ID, required: true + argument :cache_key, GraphQL::Types::String, required: true + end + + define_method(:post) { |id:, cache_key:| + cache_fragment(cache_key: {exclude_arguments: [:cache_key]}) { Post.find(id) } + } + } + ) + end + end + + it "uses #write_multi ony one time time" do + execute_query + expect(GraphQL::FragmentCache.cache_store).to have_received(:write_multi).once + end + end end end From 682cf37420d7d58918421fca4cb7e1764d415bad Mon Sep 17 00:00:00 2001 From: Martin Gruner Date: Tue, 18 Feb 2025 13:39:45 +0100 Subject: [PATCH 2/4] Fix cache key builder options handling and tests --- .../fragment_cache/cache_key_builder.rb | 8 +- .../fragment_cache/cache_key_builder_spec.rb | 8 +- spec/graphql/fragment_cache/cacher_spec.rb | 96 +++++++++++++------ 3 files changed, 78 insertions(+), 34 deletions(-) diff --git a/lib/graphql/fragment_cache/cache_key_builder.rb b/lib/graphql/fragment_cache/cache_key_builder.rb index 6a2e53a..90a044d 100644 --- a/lib/graphql/fragment_cache/cache_key_builder.rb +++ b/lib/graphql/fragment_cache/cache_key_builder.rb @@ -137,8 +137,12 @@ def path_cache_key end def include_argument?(argument_name) - return false if @options[:exclude_arguments]&.include?(argument_name) - return false if @options[:include_arguments] && !@options[:include_arguments].include?(argument_name) + exclude_arguments = @options.dig(:cache_key, :exclude_arguments) + return false if exclude_arguments&.include?(argument_name) + + include_arguments = @options.dig(:cache_key, :include_arguments) + return false if include_arguments && !include_arguments.include?(argument_name) + true end diff --git a/spec/graphql/fragment_cache/cache_key_builder_spec.rb b/spec/graphql/fragment_cache/cache_key_builder_spec.rb index 9cf79c3..f8ebadc 100644 --- a/spec/graphql/fragment_cache/cache_key_builder_spec.rb +++ b/spec/graphql/fragment_cache/cache_key_builder_spec.rb @@ -68,13 +68,13 @@ specify { is_expected.to eq "graphql/cachedPost/schema_key-cachedPost(id:#{id})[id.title.author[id.name]]" } context "when excluding arguments" do - let(:options) { {exclude_arguments: [:id]} } + let(:options) { {cache_key: {exclude_arguments: [:id]}} } specify { is_expected.to eq "graphql/cachedPost/schema_key-cachedPost()[id.title.author[id.name]]" } end context "when including arguments" do - let(:options) { {include_arguments: [:id]} } + let(:options) { {cache_key: {include_arguments: [:id]}} } specify { is_expected.to eq "graphql/cachedPost/schema_key-cachedPost(id:#{id})[id.title.author[id.name]]" } end @@ -144,13 +144,13 @@ specify { is_expected.to eq "graphql/cachedPostByComplexInput/schema_key-cachedPostByComplexInput(complex_post_input:{input_with_id:{id:#{id},int_arg:42},string_arg:woo})[id.title.author[id.name]]" } context "when excluding arguments" do - let(:options) { {exclude_arguments: [:int_arg]} } + let(:options) { {cache_key: {exclude_arguments: [:int_arg]}} } specify { is_expected.to eq "graphql/cachedPostByComplexInput/schema_key-cachedPostByComplexInput(complex_post_input:{input_with_id:{id:#{id}},string_arg:woo})[id.title.author[id.name]]" } end context "when including arguments" do - let(:options) { {include_arguments: [:complex_post_input, :input_with_id, :int_arg]} } + let(:options) { {cache_key: {include_arguments: [:complex_post_input, :input_with_id, :int_arg]}} } specify { is_expected.to eq "graphql/cachedPostByComplexInput/schema_key-cachedPostByComplexInput(complex_post_input:{input_with_id:{int_arg:42}})[id.title.author[id.name]]" } end diff --git a/spec/graphql/fragment_cache/cacher_spec.rb b/spec/graphql/fragment_cache/cacher_spec.rb index 197aae7..6566080 100644 --- a/spec/graphql/fragment_cache/cacher_spec.rb +++ b/spec/graphql/fragment_cache/cacher_spec.rb @@ -76,23 +76,6 @@ def write_multi(hash, options) end context "when cached fields have different options" do - let(:schema) do - build_schema do - query( - Class.new(Types::Query) { - field :post, Types::Post, null: true do - argument :id, GraphQL::Types::ID, required: true - argument :cache_key, GraphQL::Types::String, required: true - end - - define_method(:post) { |id:, cache_key:| - cache_fragment(query_cache_key: cache_key) { Post.find(id) } - } - } - ) - end - end - let(:query) do <<~GQL query getPost($id: ID!) { @@ -107,19 +90,38 @@ def write_multi(hash, options) GQL end - it "uses #write_multi two times with different options" do - execute_query + context "when there options are passed to cache_fragment" do + let(:schema) do + build_schema do + query( + Class.new(Types::Query) { + field :post, Types::Post, null: true do + argument :id, GraphQL::Types::ID, required: true + argument :cache_key, GraphQL::Types::String, required: true + end - args = [] - expect(GraphQL::FragmentCache.cache_store).to \ - have_received(:write_multi).exactly(2).times do |r, options| - args << options + define_method(:post) { |id:, cache_key:| + cache_fragment(query_cache_key: cache_key) { Post.find(id) } + } + } + ) end + end + + it "uses #write_multi two times with different query_cache_key options" do + execute_query + + args = [] + expect(GraphQL::FragmentCache.cache_store).to \ + have_received(:write_multi).exactly(2).times do |r, options| + args << options + end - expect(args).to eq([{query_cache_key: "1"}, {query_cache_key: "2"}]) + expect(args).to eq([{query_cache_key: "1"}, {query_cache_key: "2"}]) + end end - context "when different options exist, but should be excluded" do + context "when cache key is autogenerated" do let(:schema) do build_schema do query( @@ -130,16 +132,54 @@ def write_multi(hash, options) end define_method(:post) { |id:, cache_key:| - cache_fragment(cache_key: {exclude_arguments: [:cache_key]}) { Post.find(id) } + cache_fragment { Post.find(id) } } } ) end end - it "uses #write_multi ony one time time" do + it "writes a cache key for each argument value" do execute_query - expect(GraphQL::FragmentCache.cache_store).to have_received(:write_multi).once + + args = [] + expect(GraphQL::FragmentCache.cache_store).to \ + have_received(:write_multi).once.times do |hash, options| + args << hash + end + + expect(args.first.keys.length).to be(2) + end + + context "when arguments are excluded" do + let(:schema) do + build_schema do + query( + Class.new(Types::Query) { + field :post, Types::Post, null: true do + argument :id, GraphQL::Types::ID, required: true + argument :cache_key, GraphQL::Types::String, required: true + end + + define_method(:post) { |id:, cache_key:| + cache_fragment(cache_key: {exclude_arguments: [:cache_key]}) { Post.find(id) } + } + } + ) + end + end + + it "writes only one cache key" do + execute_query + + args = [] + expect(GraphQL::FragmentCache.cache_store).to \ + have_received(:write_multi).once.times do |hash, options| + args << hash + end + + expect(args.first.keys.length).to be(1) + end end end end From 96aa637b27a4982d4f9c84a88405369e93d1ba0d Mon Sep 17 00:00:00 2001 From: Martin Gruner Date: Tue, 18 Feb 2025 14:49:58 +0100 Subject: [PATCH 3/4] Add documentation --- README.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/README.md b/README.md index 9430db7..ff47e94 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,34 @@ class QueryType < BaseObject end ``` +### Query arguments processing + +You can influence the way that graphql arguments are include in the cache key. + +A use case might be a `:renew_cache` parameter that can be used to force a cache rewrite, +but should not be included with the cache key itself. Use `cache_key: { exclude_arguments: […]}` +to specify a list of arguments to be excluded from the implicit cache key. + +```ruby +class QueryType < BaseObject + field :post, PostType, null: true do + argument :id, ID, required: true + argument :renew_cache, Boolean, required: false + end + + def post(id:, renew_cache: false) + if renew_cache + context.scoped_set!(:renew_cache, true) + end + cache_fragment(cache_key: { exclude_arguments: [:renew_cache] }) { Post.find(id) } + end +end +``` + +Likewise, you can use `cache_key: { include_arguments: […] }` to specify an allowlist of arguments +to be included in the cache key. In this case all arguments for the cache key must be specified, including +parent arguments of nested fields. + ### User-provided cache key (custom key) In most cases you want your cache key to depend on the resolved object (say, `ActiveRecord` model). You can do that by passing an argument to the `#cache_fragment` method in a similar way to Rails views [`#cache` method](https://guides.rubyonrails.org/caching_with_rails.html#fragment-caching): From 43d188e617a72ed6b4ac398536d540935956d859 Mon Sep 17 00:00:00 2001 From: Martin Gruner Date: Wed, 19 Feb 2025 09:49:44 +0100 Subject: [PATCH 4/4] Fix rubocop --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ff47e94..42fd4ae 100644 --- a/README.md +++ b/README.md @@ -166,7 +166,7 @@ class QueryType < BaseObject if renew_cache context.scoped_set!(:renew_cache, true) end - cache_fragment(cache_key: { exclude_arguments: [:renew_cache] }) { Post.find(id) } + cache_fragment(cache_key: {exclude_arguments: [:renew_cache]}) { Post.find(id) } end end ```