diff --git a/app/graphql/resolvers/base_resolver.rb b/app/graphql/resolvers/base_resolver.rb index cf0642930ad5a3055f0694824e3288044d6c6fb3..7daff68c069a107f88b1a94f82730e22f63b28cd 100644 --- a/app/graphql/resolvers/base_resolver.rb +++ b/app/graphql/resolvers/base_resolver.rb @@ -3,27 +3,33 @@ module Resolvers class BaseResolver < GraphQL::Schema::Resolver extend ::Gitlab::Utils::Override + include ::Gitlab::Utils::StrongMemoize def self.single @single ||= Class.new(self) do + def ready?(**args) + ready, early_return = super + [ready, select_result(early_return)] + end + def resolve(**args) - super.first + select_result(super) end def single? true end + + def select_result(results) + results&.first + end end end def self.last - @last ||= Class.new(self) do - def resolve(**args) - super.last - end - - def single? - true + @last ||= Class.new(self.single) do + def select_result(results) + results&.last end end end @@ -59,6 +65,17 @@ def object end end + def synchronized_object + strong_memoize(:synchronized_object) do + case object + when BatchLoader::GraphQL + object.sync + else + object + end + end + end + def single? false end diff --git a/doc/development/api_graphql_styleguide.md b/doc/development/api_graphql_styleguide.md index c8fa90e94d4f1f3b86b82506e9f1ebdb7adac7dc..9ebcd70d1f9be1c1d338a8f2e1b31b54a9de55d8 100644 --- a/doc/development/api_graphql_styleguide.md +++ b/doc/development/api_graphql_styleguide.md @@ -619,6 +619,31 @@ lot of dependent objects. To limit the amount of queries performed, we can use `BatchLoader`. +### Correct use of `Resolver#ready?` + +Resolvers have two public API methods as part of the framework: `#ready?(**args)` and `#resolve(**args)`. +We can use `#ready?` to perform set-up, validation or early-return without invoking `#resolve`. + +Good reasons to use `#ready?` include: + +- validating mutually exclusive arguments (see [validating arguments](#validating-arguments)) +- Returning `Relation.none` if we know before-hand that no results are possible +- Performing setup such as initializing instance variables (although consider lazily initialized methods for this) + +Implementations of [`Resolver#ready?(**args)`](https://graphql-ruby.org/api-doc/1.10.9/GraphQL/Schema/Resolver#ready%3F-instance_method) should +return `(Boolean, early_return_data)` as follows: + +```ruby +def ready?(**args) + [false, 'have this instead'] +end +``` + +For this reason, whenever you call a resolver (mainly in tests - as framework +abstractions Resolvers should not be considered re-usable, finders are to be +preferred), remember to call the `ready?` method and check the boolean flag +before calling `resolve`! An example can be seen in our [`GraphQLHelpers`](https://gitlab.com/gitlab-org/gitlab/-/blob/2d395f32d2efbb713f7bc861f96147a2a67e92f2/spec/support/helpers/graphql_helpers.rb#L20-27). + ## Mutations Mutations are used to change any stored values, or to trigger @@ -785,7 +810,7 @@ def ready?(**args) end # Always remember to call `#super` - super(args) + super end ``` diff --git a/spec/graphql/resolvers/base_resolver_spec.rb b/spec/graphql/resolvers/base_resolver_spec.rb index 0a21b2797ee7cac5126c8438dee3286c487967cb..6c384349577ff0b864cadce74dc5c670c3a7d429 100644 --- a/spec/graphql/resolvers/base_resolver_spec.rb +++ b/spec/graphql/resolvers/base_resolver_spec.rb @@ -41,9 +41,35 @@ def resolve(**args) end end + context 'when the resolver returns early' do + let(:resolver) do + Class.new(described_class) do + def ready?(**args) + [false, %w(early return)] + end + + def resolve(**args) + raise 'Should not get here' + end + end + end + + it 'runs correctly in our test framework' do + expect(resolve(resolver)).to contain_exactly('early', 'return') + end + + it 'single selects the first early return value' do + expect(resolve(resolver.single)).to eq('early') + end + + it 'last selects the last early return value' do + expect(resolve(resolver.last)).to eq('return') + end + end + describe '.last' do it 'returns a subclass from the resolver' do - expect(last_resolver.last.superclass).to eq(last_resolver) + expect(last_resolver.last.ancestors).to include(last_resolver) end it 'returns the same subclass every time' do @@ -95,4 +121,28 @@ def resolve(**args) end end end + + describe '#synchronized_object' do + let(:object) { double(foo: :the_foo) } + + let(:resolver) do + Class.new(described_class) do + def resolve(**args) + [synchronized_object.foo] + end + end + end + + it 'handles raw objects' do + expect(resolve(resolver, obj: object)).to contain_exactly(:the_foo) + end + + it 'handles lazy objects' do + delayed = BatchLoader::GraphQL.for(1).batch do |_, loader| + loader.call(1, object) + end + + expect(resolve(resolver, obj: delayed)).to contain_exactly(:the_foo) + end + end end diff --git a/spec/support/helpers/graphql_helpers.rb b/spec/support/helpers/graphql_helpers.rb index b3d7f7bcece7a94bcc4c3c23bdba8b5fb6bb25ef..7082424b89977993f041292e8ecaff7290d9808d 100644 --- a/spec/support/helpers/graphql_helpers.rb +++ b/spec/support/helpers/graphql_helpers.rb @@ -11,9 +11,19 @@ def self.fieldnamerize(underscored_field_name) underscored_field_name.to_s.camelize(:lower) end - # Run a loader's named resolver + # Run a loader's named resolver in a way that closely mimics the framework. + # + # First the `ready?` method is called. If it turns out that the resolver is not + # ready, then the early return is returned instead. + # + # Then the resolve method is called. def resolve(resolver_class, obj: nil, args: {}, ctx: {}, field: nil) - resolver_class.new(object: obj, context: ctx, field: field).resolve(args) + resolver = resolver_class.new(object: obj, context: ctx, field: field) + ready, early_return = sync_all { resolver.ready?(**args) } + + return early_return unless ready + + resolver.resolve(args) end # Eagerly run a loader's named resolver @@ -51,12 +61,12 @@ def batch(max_queries: nil, &blk) # BatchLoader::GraphQL returns a wrapper, so we need to :sync in order # to get the actual values def batch_sync(max_queries: nil, &blk) - wrapper = proc do - lazy_vals = yield - lazy_vals.is_a?(Array) ? lazy_vals.map { |val| sync(val) } : sync(lazy_vals) - end + batch(max_queries: max_queries) { sync_all(&blk) } + end - batch(max_queries: max_queries, &wrapper) + def sync_all(&blk) + lazy_vals = yield + lazy_vals.is_a?(Array) ? lazy_vals.map { |val| sync(val) } : sync(lazy_vals) end def graphql_query_for(name, attributes = {}, fields = nil) diff --git a/spec/support/matchers/graphql_matchers.rb b/spec/support/matchers/graphql_matchers.rb index 3e2193a9069cec04979d57a9b2f9ea9b67b39711..e204b9ccd68527e0e5e9c0098eefc0dbcae845ba 100644 --- a/spec/support/matchers/graphql_matchers.rb +++ b/spec/support/matchers/graphql_matchers.rb @@ -85,9 +85,16 @@ def expected_field_names RSpec::Matchers.define :have_graphql_arguments do |*expected| include GraphqlHelpers + def expected_names + @names ||= Array.wrap(expected).map { |name| GraphqlHelpers.fieldnamerize(name) } + end + match do |field| - argument_names = expected.map { |name| GraphqlHelpers.fieldnamerize(name) } - expect(field.arguments.keys).to contain_exactly(*argument_names) + expect(field.arguments.keys).to contain_exactly(*expected_names) + end + + failure_message do |field| + "expected that #{field.name} would have the following fields: #{expected_names.inspect}, but it has #{field.arguments.keys.inspect}." end end