diff --git a/README.md b/README.md index 9430db7..42fd4ae 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): diff --git a/lib/graphql/fragment_cache/cache_key_builder.rb b/lib/graphql/fragment_cache/cache_key_builder.rb index f5aa5ca..90a044d 100644 --- a/lib/graphql/fragment_cache/cache_key_builder.rb +++ b/lib/graphql/fragment_cache/cache_key_builder.rb @@ -130,16 +130,26 @@ 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) + 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 + 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..f8ebadc 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) { {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) { {cache_key: {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) { {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) { {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 end end diff --git a/spec/graphql/fragment_cache/cacher_spec.rb b/spec/graphql/fragment_cache/cacher_spec.rb index a946b6d..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,16 +90,97 @@ 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 + + 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"}]) + end + end + + context "when cache key is autogenerated" 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 { Post.find(id) } + } + } + ) + end + end + + it "writes a cache key for each argument value" do + execute_query + + args = [] + expect(GraphQL::FragmentCache.cache_store).to \ + have_received(:write_multi).once.times do |hash, options| + args << hash + end - args = [] - expect(GraphQL::FragmentCache.cache_store).to \ - have_received(:write_multi).exactly(2).times do |r, options| - args << options + 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 - expect(args).to eq([{query_cache_key: "1"}, {query_cache_key: "2"}]) + 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 end