diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000..aedc15b --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +2.5.3 diff --git a/CHANGELOG.md b/CHANGELOG.md index 608e79b..672f85b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## master +- [PR#30](https://github.com/DmitryTsepelev/graphql-ruby-fragment_cache/pull/30) Add union support ([@DmitryTsepelev][]) + ## 1.0.3 (2020-08-31) - [PR#29](https://github.com/DmitryTsepelev/graphql-ruby-fragment_cache/pull/29) Cache result JSON instead of connection objects ([@DmitryTsepelev][]) diff --git a/lib/graphql/fragment_cache/cache_key_builder.rb b/lib/graphql/fragment_cache/cache_key_builder.rb index 9db7364..8ca2b1e 100644 --- a/lib/graphql/fragment_cache/cache_key_builder.rb +++ b/lib/graphql/fragment_cache/cache_key_builder.rb @@ -32,9 +32,23 @@ def alias?(val) end refine ::GraphQL::Execution::Lookahead do + using RubyNext + def selection_with_alias(name, **kwargs) - return selection(name, **kwargs) if selects?(name, **kwargs) - alias_selection(name, **kwargs) + # In case of union we have to pass a type of object explicitly + # More info https://github.com/rmosolgo/graphql-ruby/pull/3007 + if @selected_type.kind.union? + # TODO: we need to guess a type of an object at path to pass it + kwargs[:selected_type] = @query.context.namespace(:interpreter)[:current_object].class + end + + selection(name, **kwargs).then do |next_selection| + if next_selection.is_a?(GraphQL::Execution::Lookahead::NullLookahead) + alias_selection(name, **kwargs) + else + next_selection + end + end end def alias_selection(name, selected_type: @selected_type, arguments: nil) @@ -48,7 +62,9 @@ def alias_selection(name, selected_type: @selected_type, arguments: nil) # From https://github.com/rmosolgo/graphql-ruby/blob/1a9a20f3da629e63ea8e5ee8400be82218f9edc3/lib/graphql/execution/lookahead.rb#L91 next_field_defn = get_class_based_field(selected_type, next_field_name) - alias_selections[name] = + alias_name = "#{name}_#{selected_type.name}" + + alias_selections[alias_name] = if next_field_defn next_nodes = [] arguments = @query.arguments_for(alias_node, next_field_defn) diff --git a/spec/graphql/fragment_cache/cache_key_builder_spec.rb b/spec/graphql/fragment_cache/cache_key_builder_spec.rb index 5d8fe80..aad8a42 100644 --- a/spec/graphql/fragment_cache/cache_key_builder_spec.rb +++ b/spec/graphql/fragment_cache/cache_key_builder_spec.rb @@ -57,6 +57,7 @@ GQL end + specify { is_expected.to eq "schema_key/cachedPost(id:#{id})[id.title.author[id.name]]" } end @@ -77,7 +78,6 @@ end let(:path) { ["cachedPostByInput"] } - let(:variables) { {inputWithId: {id: id, intArg: 42}} } specify { is_expected.to eq "schema_key/cachedPostByInput(input_with_id:{id:#{id},int_arg:42})[id.title.author[id.name]]" } @@ -147,6 +147,7 @@ GQL end + specify { is_expected.to eq "schema_key/cachedPost(id:#{id})[id.title.author[id.name]]" } context "when nested fragment is used" do @@ -216,7 +217,6 @@ } GQL end - let(:path) { ["posts", 0, "cachedTitle"] } specify { is_expected.to eq "schema_key/posts/0/cachedTitle[]" } @@ -265,4 +265,73 @@ specify { is_expected.to eq "schema_key/post(id:1)/cachedAuthor[name]" } end + + context "when query has union type" do + let(:path) { ["lastActivity", "cachedAvatarUrl"] } + + let(:query) do + <<~GQL + query getLastActivity { + lastActivity { + ...on PostType { + id + cachedAvatarUrl + } + ...on UserType { + id + cachedAvatarUrl + } + } + } + GQL + end + + specify { is_expected.to eq "schema_key/lastActivity/cachedAvatarUrl[]" } + + context "when array of union typed objects is returned" do + let(:query) do + <<~GQL + query getFeed { + feed { + ...on PostType { + id + cachedAvatarUrl + } + ...on UserType { + id + cachedAvatarUrl + } + } + } + GQL + end + + let(:path) { ["feed", 0, "cachedAvatarUrl"] } + + specify { is_expected.to eq "schema_key/feed/0/cachedAvatarUrl[]" } + + context "when cached field has alias" do + let(:query) do + <<~GQL + query getFeed { + feed { + ...on PostType { + id + avatarUrl: cachedAvatarUrl + } + ...on UserType { + id + avatarUrl: cachedAvatarUrl + } + } + } + GQL + end + + let(:path) { ["feed", 0, "avatarUrl"] } + + specify { is_expected.to eq "schema_key/feed/0/cachedAvatarUrl[]" } + end + end + end end diff --git a/spec/graphql/fragment_cache/object_helpers_spec.rb b/spec/graphql/fragment_cache/object_helpers_spec.rb index fff914d..9fc3bd3 100644 --- a/spec/graphql/fragment_cache/object_helpers_spec.rb +++ b/spec/graphql/fragment_cache/object_helpers_spec.rb @@ -577,4 +577,124 @@ def post(id:, expires_in: nil) expect(::Post).not_to have_received(:all) end end + + describe "union caching" do + let!(:post) { Post.create(id: 1, title: "Post #1") } + let!(:user) { User.create(id: 2, name: "User #2") } + + let(:schema) do + build_schema do + query( + Class.new(Types::Query) { + field :last_activity, Types::Activity, null: false + + define_method(:last_activity, -> { ::Post.find(1) }) + } + ) + end + end + + let(:query) do + <<~GQL + query getLastActivity { + lastActivity { + ...on PostType { + id + cachedAvatarUrl + } + ...on UserType { + id + cachedAvatarUrl + } + } + } + GQL + end + + it "returns cached data" do + expect(execute_query.dig("data")).to eq( + "lastActivity" => + {"cachedAvatarUrl" => "http://example.com/img/posts/#{post.id}", "id" => post.id.to_s} + ) + end + + context "when unions are nested" do + let(:query) do + <<~GQL + query getLastActivity { + lastActivity { + ...on PostType { + id + relatedActivity { + ...on PostType { + id + cachedAvatarUrl + } + ...on UserType { + id + cachedAvatarUrl + } + } + } + ...on UserType { + id + } + } + } + GQL + end + + it "returns cached data" do + expect(execute_query.dig("data")).to eq( + "lastActivity" => { + "id" => "1", + "relatedActivity" => { + "cachedAvatarUrl" => "http://example.com/img/posts/#{user.id}", + "id" => user.id.to_s + } + } + ) + end + end + + context "when array of union typed objects is returned" do + let(:schema) do + build_schema do + query( + Class.new(Types::Query) { + field :feed, [Types::Activity], null: false + + define_method(:feed, -> { ::Post.all + ::User.all }) + } + ) + end + end + + let(:query) do + <<~GQL + query getFeed { + feed { + ...on PostType { + id + cachedAvatarUrl + } + ...on UserType { + id + cachedAvatarUrl + } + } + } + GQL + end + + it "returns cached data" do + expect(execute_query.dig("data")).to eq( + "feed" => [ + {"cachedAvatarUrl" => "http://example.com/img/posts/#{post.id}", "id" => post.id.to_s}, + {"cachedAvatarUrl" => "http://example.com/img/users/#{user.id}", "id" => user.id.to_s} + ] + ) + end + end + end end diff --git a/spec/graphql/fragment_cache/rails/cache_key_builder_spec.rb b/spec/graphql/fragment_cache/rails/cache_key_builder_spec.rb index 5ce6c47..e93fee1 100644 --- a/spec/graphql/fragment_cache/rails/cache_key_builder_spec.rb +++ b/spec/graphql/fragment_cache/rails/cache_key_builder_spec.rb @@ -26,6 +26,7 @@ let(:object) { Post.find(42) } let(:query_obj) { GraphQL::Query.new(schema, query, variables: variables) } + let(:selected_type) { Types::Post } # Make cache keys raw for easier debugging let(:schema_cache_key) { "schema_key" } @@ -34,7 +35,11 @@ allow(Digest::SHA1).to receive(:hexdigest) { |val| val } end - subject { described_class.call(object: object, query: query_obj, path: path) } + subject do + described_class.call( + object: object, query: query_obj, selected_type: selected_type, path: path + ) + end it "uses Cache.expand_cache_key" do allow(ActiveSupport::Cache).to receive(:expand_cache_key).with(object) { "as:cache:key" } diff --git a/spec/support/models/post.rb b/spec/support/models/post.rb index 9d504be..6625d56 100644 --- a/spec/support/models/post.rb +++ b/spec/support/models/post.rb @@ -4,7 +4,7 @@ class Post class << self def find(id) store.fetch(id.to_i) do - author = User.new(id: id, name: "User ##{id}") + author = User.fetch(id) new(id: id, title: "Post ##{id}", author: author) end end diff --git a/spec/support/models/user.rb b/spec/support/models/user.rb index 27b84d4..566bd0a 100644 --- a/spec/support/models/user.rb +++ b/spec/support/models/user.rb @@ -4,6 +4,26 @@ class User attr_reader :id attr_accessor :name + class << self + def fetch(id) + store.fetch(id.to_i) do + new(id: id, name: "User ##{id}") + end + end + + def all + @store.values + end + + def create(id:, **attributes) + store[id] = new(id: id, **attributes) + end + + def store + @store ||= {} + end + end + def initialize(id:, name:) @id = id @name = name diff --git a/spec/support/test_schema.rb b/spec/support/test_schema.rb index 2f2a2b8..2190d84 100644 --- a/spec/support/test_schema.rb +++ b/spec/support/test_schema.rb @@ -10,6 +10,14 @@ class User < Base field :id, ID, null: false field :name, String, null: false + field :cached_avatar_url, String, null: true + + def cached_avatar_url + cache_fragment { "http://example.com/img/users/#{object.id}" } + end + end + + class Activity < GraphQL::Schema::Union end class Post < Base @@ -18,14 +26,36 @@ class Post < Base field :id, ID, null: false field :title, String, null: false field :cached_title, String, null: false, cache_fragment: true, method: :title + field :cached_avatar_url, String, null: true field :author, User, null: false field :cached_author, User, null: false + field :related_activity, Activity, null: true field :meta, String, null: true def cached_author cache_fragment { object.author } end + + def cached_avatar_url + cache_fragment { "http://example.com/img/posts/#{object.id}" } + end + + def related_activity + ::User.all.last + end + end + + class Activity < GraphQL::Schema::Union + graphql_name "ActivityType" + + description "Represents chat message" + + possible_types Post, User + + def self.resolve_type(object, _context) + Kernel.const_get("Types::#{object.class.name}") + end end class PostInput < GraphQL::Schema::InputObject @@ -59,6 +89,10 @@ class Query < Base argument :complex_post_input, ComplexPostInput, required: true end + field :feed, [Activity], null: false + + field :last_activity, Activity, null: false + def post(id:) ::Post.find(id) end @@ -67,6 +101,14 @@ def posts ::Post.all end + def feed + ::Post.all + ::User.all + end + + def last_activity + ::Post.find(1) + end + def cached_post(id:) cache_fragment { ::Post.find(id) } end