Commit e7397ee1 authored by charlie ablett's avatar charlie ablett

Merge branch 'ajk-graphql-field-extensions-over-instrumentation' into 'master'

Replace calls-gitaly instrumentation with  a field extension [RUN AS-IF-FOSS]

See merge request gitlab-org/gitlab!55306
parents dcfda9ad f5c82c28
...@@ -13,8 +13,6 @@ class GitlabSchema < GraphQL::Schema ...@@ -13,8 +13,6 @@ class GitlabSchema < GraphQL::Schema
use GraphQL::Pagination::Connections use GraphQL::Pagination::Connections
use BatchLoader::GraphQL use BatchLoader::GraphQL
use Gitlab::Graphql::Authorize use Gitlab::Graphql::Authorize
use Gitlab::Graphql::Present
use Gitlab::Graphql::CallsGitaly
use Gitlab::Graphql::Pagination::Connections use Gitlab::Graphql::Pagination::Connections
use Gitlab::Graphql::GenericTracing use Gitlab::Graphql::GenericTracing
use Gitlab::Graphql::Timeout, max_seconds: Gitlab.config.gitlab.graphql_timeout use Gitlab::Graphql::Timeout, max_seconds: Gitlab.config.gitlab.graphql_timeout
......
...@@ -12,8 +12,17 @@ module Resolvers ...@@ -12,8 +12,17 @@ module Resolvers
@requires_argument = true @requires_argument = true
end end
def self.calls_gitaly!
@calls_gitaly = true
end
def self.field_options def self.field_options
super.merge(requires_argument: @requires_argument) extra_options = {
requires_argument: @requires_argument,
calls_gitaly: @calls_gitaly
}.compact
super.merge(extra_options)
end end
def self.singular_type def self.singular_type
......
...@@ -4,6 +4,8 @@ module Resolvers ...@@ -4,6 +4,8 @@ module Resolvers
class LastCommitResolver < BaseResolver class LastCommitResolver < BaseResolver
type Types::CommitType, null: true type Types::CommitType, null: true
calls_gitaly!
alias_method :tree, :object alias_method :tree, :object
def resolve(**args) def resolve(**args)
......
...@@ -3,19 +3,30 @@ ...@@ -3,19 +3,30 @@
module Resolvers module Resolvers
module Metrics module Metrics
class DashboardResolver < Resolvers::BaseResolver class DashboardResolver < Resolvers::BaseResolver
type Types::Metrics::DashboardType, null: true
calls_gitaly!
argument :path, GraphQL::STRING_TYPE, argument :path, GraphQL::STRING_TYPE,
required: true, required: true,
description: "Path to a file which defines metrics dashboard eg: 'config/prometheus/common_metrics.yml'." description: "Path to a file which defines metrics dashboard " \
"eg: 'config/prometheus/common_metrics.yml'."
type Types::Metrics::DashboardType, null: true
alias_method :environment, :object alias_method :environment, :object
def resolve(**args) def resolve(**args)
return unless environment return unless environment
::PerformanceMonitoring::PrometheusDashboard ::PerformanceMonitoring::PrometheusDashboard.find_for(**args, **service_params)
.find_for(project: environment.project, user: context[:current_user], path: args[:path], options: { environment: environment }) end
private
def service_params
{
project: environment.project,
user: current_user,
options: { environment: environment }
}
end end
end end
end end
......
...@@ -8,6 +8,7 @@ module Resolvers ...@@ -8,6 +8,7 @@ module Resolvers
type Types::Snippets::BlobType.connection_type, null: true type Types::Snippets::BlobType.connection_type, null: true
authorize :read_snippet authorize :read_snippet
calls_gitaly!
alias_method :snippet, :object alias_method :snippet, :object
......
...@@ -4,6 +4,8 @@ module Resolvers ...@@ -4,6 +4,8 @@ module Resolvers
class TreeResolver < BaseResolver class TreeResolver < BaseResolver
type Types::Tree::TreeType, null: true type Types::Tree::TreeType, null: true
calls_gitaly!
argument :path, GraphQL::STRING_TYPE, argument :path, GraphQL::STRING_TYPE,
required: false, required: false,
default_value: '', default_value: '',
......
...@@ -9,16 +9,25 @@ module Types ...@@ -9,16 +9,25 @@ module Types
DEFAULT_COMPLEXITY = 1 DEFAULT_COMPLEXITY = 1
def initialize(*args, **kwargs, &block) def initialize(**kwargs, &block)
@calls_gitaly = !!kwargs.delete(:calls_gitaly) @calls_gitaly = !!kwargs.delete(:calls_gitaly)
@constant_complexity = !!kwargs[:complexity] @constant_complexity = kwargs[:complexity].is_a?(Integer) && kwargs[:complexity] > 0
@requires_argument = !!kwargs.delete(:requires_argument) @requires_argument = !!kwargs.delete(:requires_argument)
kwargs[:complexity] = field_complexity(kwargs[:resolver_class], kwargs[:complexity]) kwargs[:complexity] = field_complexity(kwargs[:resolver_class], kwargs[:complexity])
@feature_flag = kwargs[:feature_flag] @feature_flag = kwargs[:feature_flag]
kwargs = check_feature_flag(kwargs) kwargs = check_feature_flag(kwargs)
kwargs = gitlab_deprecation(kwargs) kwargs = gitlab_deprecation(kwargs)
super(*args, **kwargs, &block) super(**kwargs, &block)
# We want to avoid the overhead of this in prod
extension ::Gitlab::Graphql::CallsGitaly::FieldExtension if Gitlab.dev_or_test_env?
extension ::Gitlab::Graphql::Present::FieldExtension
end
def may_call_gitaly?
@constant_complexity || @calls_gitaly
end end
def requires_argument? def requires_argument?
...@@ -54,8 +63,10 @@ module Types ...@@ -54,8 +63,10 @@ module Types
end end
def check_feature_flag(args) def check_feature_flag(args)
args[:description] = feature_documentation_message(args[:feature_flag], args[:description]) if args[:feature_flag].present? ff = args.delete(:feature_flag)
args.delete(:feature_flag) return args unless ff.present?
args[:description] = feature_documentation_message(ff, args[:description])
args args
end end
...@@ -78,7 +89,9 @@ module Types ...@@ -78,7 +89,9 @@ module Types
# items which can be loaded. # items which can be loaded.
proc do |ctx, args, child_complexity| proc do |ctx, args, child_complexity|
# Resolvers may add extra complexity depending on used arguments # Resolvers may add extra complexity depending on used arguments
complexity = child_complexity + self.resolver&.try(:resolver_complexity, args, child_complexity: child_complexity).to_i complexity = child_complexity + resolver&.try(
:resolver_complexity, args, child_complexity: child_complexity
).to_i
complexity += 1 if calls_gitaly? complexity += 1 if calls_gitaly?
complexity += complexity * connection_complexity_multiplier(ctx, args) complexity += complexity * connection_complexity_multiplier(ctx, args)
...@@ -93,7 +106,7 @@ module Types ...@@ -93,7 +106,7 @@ module Types
page_size = field_defn.connection_max_page_size || ctx.schema.default_max_page_size page_size = field_defn.connection_max_page_size || ctx.schema.default_max_page_size
limit_value = [args[:first], args[:last], page_size].compact.min limit_value = [args[:first], args[:last], page_size].compact.min
multiplier = self.resolver&.try(:complexity_multiplier, args).to_f multiplier = resolver&.try(:complexity_multiplier, args).to_f
limit_value * multiplier limit_value * multiplier
end end
end end
......
...@@ -16,9 +16,10 @@ module Types ...@@ -16,9 +16,10 @@ module Types
end end
def current_user_todos(state: nil) def current_user_todos(state: nil)
state ||= %i(done pending) # TodosFinder treats a `nil` state param as `pending` state ||= %i[done pending] # TodosFinder treats a `nil` state param as `pending`
klass = unpresented.class
TodosFinder.new(current_user, state: state, type: object.class.name, target_id: object.id).execute TodosFinder.new(current_user, state: state, type: klass.name, target_id: object.id).execute
end end
end end
end end
...@@ -14,7 +14,6 @@ module Types ...@@ -14,7 +14,6 @@ module Types
field :plain_data, GraphQL::STRING_TYPE, field :plain_data, GraphQL::STRING_TYPE,
description: 'Blob plain highlighted data.', description: 'Blob plain highlighted data.',
calls_gitaly: true,
null: true null: true
field :raw_path, GraphQL::STRING_TYPE, field :raw_path, GraphQL::STRING_TYPE,
......
...@@ -15,6 +15,7 @@ module Types ...@@ -15,6 +15,7 @@ module Types
field :web_path, GraphQL::STRING_TYPE, null: true, field :web_path, GraphQL::STRING_TYPE, null: true,
description: 'Web path of the blob.' description: 'Web path of the blob.'
field :lfs_oid, GraphQL::STRING_TYPE, null: true, field :lfs_oid, GraphQL::STRING_TYPE, null: true,
calls_gitaly: true,
description: 'LFS ID of the blob.' description: 'LFS ID of the blob.'
field :mode, GraphQL::STRING_TYPE, null: true, field :mode, GraphQL::STRING_TYPE, null: true,
description: 'Blob mode in numeric format.' description: 'Blob mode in numeric format.'
......
# frozen_string_literal: true
module Gitlab
module Graphql
# Wraps the field resolution to count Gitaly calls before and after.
# Raises an error if the field calls Gitaly but hadn't declared such.
module CallsGitaly
extend ActiveSupport::Concern
def self.use(schema_definition)
schema_definition.instrument(:field, Gitlab::Graphql::CallsGitaly::Instrumentation.new, after_built_ins: true)
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Graphql
module CallsGitaly
# Check if any `calls_gitaly: true` declarations need to be added
#
# See BaseField: this extension is not applied if the field does not
# need it (i.e. it has a constant complexity or knows that it calls
# gitaly)
class FieldExtension < ::GraphQL::Schema::FieldExtension
include Laziness
def resolve(object:, arguments:, **rest)
yield(object, arguments, [current_gitaly_call_count, accounted_for])
end
def after_resolve(value:, memo:, **rest)
(value, count) = value_with_count(value, memo)
calls_gitaly_check(count)
accounted_for(count)
value
end
private
# Resolutions are not nested nicely (due to laziness), so we have to
# know not just how many calls were made before resolution started, but
# also how many were accounted for by fields with the correct settings
# in between.
#
# e.g. the following is not just plausible, but common:
#
# enter A.user (lazy)
# enter A.x
# leave A.x
# enter A.calls_gitaly
# leave A.calls_gitaly (accounts for 1 call)
# leave A.user
#
# In this circumstance we need to mark the calls made by A.calls_gitaly
# as accounted for, even though they were made after we yielded
# in A.user
def value_with_count(value, (previous_count, previous_accounted_for))
newly_accounted_for = accounted_for - previous_accounted_for
value = force(value)
count = [current_gitaly_call_count - (previous_count + newly_accounted_for), 0].max
[value, count]
end
def current_gitaly_call_count
Gitlab::GitalyClient.get_request_count || 0
end
def calls_gitaly_check(calls)
return if calls < 1 || field.may_call_gitaly?
error = RuntimeError.new(<<~ERROR)
#{field_name} unexpectedly calls Gitaly!
Please either specify a constant complexity or add `calls_gitaly: true`
to the field declaration
ERROR
Gitlab::ErrorTracking.track_and_raise_for_dev_exception(error)
end
def accounted_for(count = nil)
return 0 unless Gitlab::SafeRequestStore.active?
Gitlab::SafeRequestStore["graphql_gitaly_accounted_for"] ||= 0
if count.nil?
Gitlab::SafeRequestStore["graphql_gitaly_accounted_for"]
else
Gitlab::SafeRequestStore["graphql_gitaly_accounted_for"] += count
end
end
def field_name
"#{field.owner.graphql_name}.#{field.graphql_name}"
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Graphql
module CallsGitaly
class Instrumentation
# Check if any `calls_gitaly: true` declarations need to be added
# Do nothing if a constant complexity was provided
def instrument(_type, field)
type_object = field.metadata[:type_class]
return field unless type_object.respond_to?(:calls_gitaly?)
return field if type_object.constant_complexity? || type_object.calls_gitaly?
old_resolver_proc = field.resolve_proc
gitaly_wrapped_resolve = -> (typed_object, args, ctx) do
previous_gitaly_call_count = Gitlab::GitalyClient.get_request_count
result = old_resolver_proc.call(typed_object, args, ctx)
current_gitaly_call_count = Gitlab::GitalyClient.get_request_count
calls_gitaly_check(type_object, current_gitaly_call_count - previous_gitaly_call_count)
result
end
field.redefine do
resolve(gitaly_wrapped_resolve)
end
end
def calls_gitaly_check(type_object, calls)
return if calls < 1
# Will inform you if there needs to be `calls_gitaly: true` as a kwarg in the field declaration
# if there is at least 1 Gitaly call involved with the field resolution.
error = RuntimeError.new("Gitaly is called for field '#{type_object.name}' on #{type_object.owner.try(:name)} - please either specify a constant complexity or add `calls_gitaly: true` to the field declaration")
Gitlab::ErrorTracking.track_and_raise_for_dev_exception(error)
end
end
end
end
end
...@@ -12,11 +12,30 @@ module Gitlab ...@@ -12,11 +12,30 @@ module Gitlab
def self.presenter_class def self.presenter_class
@presenter_class @presenter_class
end end
def self.present(object, attrs)
klass = @presenter_class
return object if !klass || object.is_a?(klass)
@presenter_class.new(object, **attrs)
end
end
def unpresented
unwrapped || object
end end
def self.use(schema_definition) def present(object_type, attrs)
schema_definition.instrument(:field, ::Gitlab::Graphql::Present::Instrumentation.new) return unless object_type.respond_to?(:present)
self.unwrapped ||= object
# @object belongs to Schema::Object, which does not expose a writer.
@object = object_type.present(unwrapped, attrs) # rubocop: disable Gitlab/ModuleWithInstanceVariables
end end
private
attr_accessor :unwrapped
end end
end end
end end
# frozen_string_literal: true
module Gitlab
module Graphql
module Present
class FieldExtension < ::GraphQL::Schema::FieldExtension
SAFE_CONTEXT_KEYS = %i[current_user].freeze
def resolve(object:, arguments:, context:)
attrs = safe_context_values(context)
# We need to handle the object being either a Schema::Object or an
# inner Schema::Object#object. This depends on whether the field
# has a @resolver_proc or not.
if object.is_a?(::Types::BaseObject)
object.present(field.owner, attrs)
yield(object, arguments)
else
# This is the legacy code-path, hit if the field has a @resolver_proc
# TODO: remove this when resolve procs are removed from the
# graphql-ruby library, and all field instrumentation is removed.
# See: https://github.com/rmosolgo/graphql-ruby/issues/3385
presented = field.owner.try(:present, object, attrs) || object
yield(presented, arguments)
end
end
private
def safe_context_values(context)
context.to_h.slice(*SAFE_CONTEXT_KEYS)
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Graphql
module Present
class Instrumentation
SAFE_CONTEXT_KEYS = %i[current_user].freeze
def instrument(type, field)
return field unless field.metadata[:type_class]
presented_in = field.metadata[:type_class].owner
return field unless presented_in.respond_to?(:presenter_class)
return field unless presented_in.presenter_class
old_resolver = field.resolve_proc
resolve_with_presenter = -> (presented_type, args, context) do
# We need to wrap the original presentation type into a type that
# uses the presenter as an object.
object = presented_type.object
if object.is_a?(presented_in.presenter_class)
next old_resolver.call(presented_type, args, context)
end
attrs = safe_context_values(context)
presenter = presented_in.presenter_class.new(object, **attrs)
# we have to use the new `authorized_new` method, as `new` is protected
wrapped = presented_type.class.authorized_new(presenter, context)
old_resolver.call(wrapped, args, context)
end
field.redefine do
resolve(resolve_with_presenter)
end
end
private
def safe_context_values(context)
context.to_h.slice(*SAFE_CONTEXT_KEYS)
end
end
end
end
end
...@@ -18,14 +18,6 @@ RSpec.describe GitlabSchema do ...@@ -18,14 +18,6 @@ RSpec.describe GitlabSchema do
expect(field_instrumenters).to include(instance_of(::Gitlab::Graphql::Authorize::Instrumentation)) expect(field_instrumenters).to include(instance_of(::Gitlab::Graphql::Authorize::Instrumentation))
end end
it 'enables using presenters' do
expect(field_instrumenters).to include(instance_of(::Gitlab::Graphql::Present::Instrumentation))
end
it 'enables using gitaly call checker' do
expect(field_instrumenters).to include(instance_of(::Gitlab::Graphql::CallsGitaly::Instrumentation))
end
it 'has the base mutation' do it 'has the base mutation' do
expect(described_class.mutation).to eq(::Types::MutationType) expect(described_class.mutation).to eq(::Types::MutationType)
end end
...@@ -47,7 +39,7 @@ RSpec.describe GitlabSchema do ...@@ -47,7 +39,7 @@ RSpec.describe GitlabSchema do
end end
describe '.execute' do describe '.execute' do
context 'for different types of users' do context 'with different types of users' do
context 'when no context' do context 'when no context' do
it 'returns DEFAULT_MAX_COMPLEXITY' do it 'returns DEFAULT_MAX_COMPLEXITY' do
expect(GraphQL::Schema) expect(GraphQL::Schema)
...@@ -78,13 +70,15 @@ RSpec.describe GitlabSchema do ...@@ -78,13 +70,15 @@ RSpec.describe GitlabSchema do
context 'when a logged in user' do context 'when a logged in user' do
it 'returns AUTHENTICATED_COMPLEXITY' do it 'returns AUTHENTICATED_COMPLEXITY' do
expect(GraphQL::Schema).to receive(:execute).with('query', hash_including(max_complexity: GitlabSchema::AUTHENTICATED_COMPLEXITY)) expect(GraphQL::Schema).to receive(:execute)
.with('query', hash_including(max_complexity: GitlabSchema::AUTHENTICATED_COMPLEXITY))
described_class.execute('query', context: { current_user: user }) described_class.execute('query', context: { current_user: user })
end end
it 'returns AUTHENTICATED_MAX_DEPTH' do it 'returns AUTHENTICATED_MAX_DEPTH' do
expect(GraphQL::Schema).to receive(:execute).with('query', hash_including(max_depth: GitlabSchema::AUTHENTICATED_MAX_DEPTH)) expect(GraphQL::Schema).to receive(:execute)
.with('query', hash_including(max_depth: GitlabSchema::AUTHENTICATED_MAX_DEPTH))
described_class.execute('query', context: { current_user: user }) described_class.execute('query', context: { current_user: user })
end end
...@@ -94,7 +88,8 @@ RSpec.describe GitlabSchema do ...@@ -94,7 +88,8 @@ RSpec.describe GitlabSchema do
it 'returns ADMIN_COMPLEXITY' do it 'returns ADMIN_COMPLEXITY' do
user = build :user, :admin user = build :user, :admin
expect(GraphQL::Schema).to receive(:execute).with('query', hash_including(max_complexity: GitlabSchema::ADMIN_COMPLEXITY)) expect(GraphQL::Schema).to receive(:execute)
.with('query', hash_including(max_complexity: GitlabSchema::ADMIN_COMPLEXITY))
described_class.execute('query', context: { current_user: user }) described_class.execute('query', context: { current_user: user })
end end
...@@ -130,7 +125,7 @@ RSpec.describe GitlabSchema do ...@@ -130,7 +125,7 @@ RSpec.describe GitlabSchema do
end end
describe '.object_from_id' do describe '.object_from_id' do
context 'for subclasses of `ApplicationRecord`' do context 'with subclasses of `ApplicationRecord`' do
let_it_be(:user) { create(:user) } let_it_be(:user) { create(:user) }
it 'returns the correct record' do it 'returns the correct record' do
...@@ -162,7 +157,7 @@ RSpec.describe GitlabSchema do ...@@ -162,7 +157,7 @@ RSpec.describe GitlabSchema do
end end
end end
context 'for classes that are not ActiveRecord subclasses and have implemented .lazy_find' do context 'with classes that are not ActiveRecord subclasses and have implemented .lazy_find' do
it 'returns the correct record' do it 'returns the correct record' do
note = create(:discussion_note_on_merge_request) note = create(:discussion_note_on_merge_request)
...@@ -182,7 +177,7 @@ RSpec.describe GitlabSchema do ...@@ -182,7 +177,7 @@ RSpec.describe GitlabSchema do
end end
end end
context 'for other classes' do context 'with other classes' do
# We cannot use an anonymous class here as `GlobalID` expects `.name` not # We cannot use an anonymous class here as `GlobalID` expects `.name` not
# to return `nil` # to return `nil`
before do before do
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::Graphql::CallsGitaly::FieldExtension, :request_store do
include GraphqlHelpers
let(:field_args) { {} }
let(:owner) { fresh_object_type }
let(:field) do
::Types::BaseField.new(name: 'value', type: GraphQL::STRING_TYPE, null: true, owner: owner, **field_args)
end
def resolve_value
resolve_field(field, { value: 'foo' }, object_type: owner)
end
context 'when the field calls gitaly' do
before do
owner.define_method :value do
Gitlab::SafeRequestStore['gitaly_call_actual'] = 1
'fresh-from-the-gitaly-mines!'
end
end
context 'when the field has a constant complexity' do
let(:field_args) { { complexity: 100 } }
it 'allows the call' do
expect { resolve_value }.not_to raise_error
end
end
context 'when the field declares that it calls gitaly' do
let(:field_args) { { calls_gitaly: true } }
it 'allows the call' do
expect { resolve_value }.not_to raise_error
end
end
context 'when the field does not have these arguments' do
let(:field_args) { {} }
it 'notices, and raises, mentioning the field' do
expect { resolve_value }.to raise_error(include('Object.value'))
end
end
end
context 'when it does not call gitaly' do
let(:field_args) { {} }
it 'does not raise' do
value = resolve_value
expect(value).to eq 'foo'
end
end
context 'when some field calls gitaly while we were waiting' do
let(:extension) { described_class.new(field: field, options: {}) }
it 'is acceptable if all are accounted for' do
object = :anything
arguments = :any_args
::Gitlab::SafeRequestStore['gitaly_call_actual'] = 3
::Gitlab::SafeRequestStore['graphql_gitaly_accounted_for'] = 0
expect do |b|
extension.resolve(object: object, arguments: arguments, &b)
end.to yield_with_args(object, arguments, [3, 0])
::Gitlab::SafeRequestStore['gitaly_call_actual'] = 13
::Gitlab::SafeRequestStore['graphql_gitaly_accounted_for'] = 10
expect { extension.after_resolve(value: 'foo', memo: [3, 0]) }.not_to raise_error
end
it 'is unacceptable if some of the calls are unaccounted for' do
::Gitlab::SafeRequestStore['gitaly_call_actual'] = 10
::Gitlab::SafeRequestStore['graphql_gitaly_accounted_for'] = 9
expect { extension.after_resolve(value: 'foo', memo: [0, 0]) }.to raise_error(include('Object.value'))
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::Graphql::CallsGitaly::Instrumentation do
subject { described_class.new }
describe '#calls_gitaly_check' do
let(:gitaly_field) { Types::BaseField.new(name: 'test', type: GraphQL::STRING_TYPE, null: true, calls_gitaly: true) }
let(:no_gitaly_field) { Types::BaseField.new(name: 'test', type: GraphQL::STRING_TYPE, null: true, calls_gitaly: false) }
context 'if there are no Gitaly calls' do
it 'does not raise an error if calls_gitaly is false' do
expect { subject.send(:calls_gitaly_check, no_gitaly_field, 0) }.not_to raise_error
end
end
context 'if there is at least 1 Gitaly call' do
it 'raises an error if calls_gitaly: is false or not defined' do
expect { subject.send(:calls_gitaly_check, no_gitaly_field, 1) }.to raise_error(/specify a constant complexity or add `calls_gitaly: true`/)
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::Graphql::Present::FieldExtension do
include GraphqlHelpers
let_it_be(:user) { create(:user) }
let(:object) { double(value: 'foo') }
let(:owner) { fresh_object_type }
let(:field_name) { 'value' }
let(:field) do
::Types::BaseField.new(name: field_name, type: GraphQL::STRING_TYPE, null: true, owner: owner)
end
let(:base_presenter) do
Class.new(SimpleDelegator) do
def initialize(object, **options)
super(object)
@object = object
@options = options
end
end
end
def resolve_value
resolve_field(field, object, current_user: user, object_type: owner)
end
context 'when the object does not declare a presenter' do
it 'does not affect normal resolution' do
expect(resolve_value).to eq 'foo'
end
end
describe 'interactions with inheritance' do
def parent
type = fresh_object_type('Parent')
type.present_using(provide_foo)
type.field :foo, ::GraphQL::INT_TYPE, null: true
type.field :value, ::GraphQL::STRING_TYPE, null: true
type
end
def child
type = Class.new(parent)
type.graphql_name 'Child'
type.present_using(provide_bar)
type.field :bar, ::GraphQL::INT_TYPE, null: true
type
end
def provide_foo
Class.new(base_presenter) do
def foo
100
end
end
end
def provide_bar
Class.new(base_presenter) do
def bar
101
end
end
end
it 'can resolve value, foo and bar' do
type = child
value = resolve_field(:value, object, object_type: type)
foo = resolve_field(:foo, object, object_type: type)
bar = resolve_field(:bar, object, object_type: type)
expect([value, foo, bar]).to eq ['foo', 100, 101]
end
end
shared_examples 'calling the presenter method' do
it 'calls the presenter method' do
expect(resolve_value).to eq presenter.new(object, current_user: user).send(field_name)
end
end
context 'when the object declares a presenter' do
before do
owner.present_using(presenter)
end
context 'when the presenter overrides the original method' do
def twice
Class.new(base_presenter) do
def value
@object.value * 2
end
end
end
let(:presenter) { twice }
it_behaves_like 'calling the presenter method'
end
# This is exercised here using an explicit `resolve:` proc, but
# @resolver_proc values are used in field instrumentation as well.
context 'when the field uses a resolve proc' do
let(:presenter) { base_presenter }
let(:field) do
::Types::BaseField.new(
name: field_name,
type: GraphQL::STRING_TYPE,
null: true,
owner: owner,
resolve: ->(obj, args, ctx) { 'Hello from a proc' }
)
end
specify { expect(resolve_value).to eq 'Hello from a proc' }
end
context 'when the presenter provides a new method' do
def presenter
Class.new(base_presenter) do
def current_username
"Hello #{@options[:current_user]&.username} from the presenter!"
end
end
end
context 'when we select the original field' do
it 'is unaffected' do
expect(resolve_value).to eq 'foo'
end
end
context 'when we select the new field' do
let(:field_name) { 'current_username' }
it_behaves_like 'calling the presenter method'
end
end
end
end
...@@ -8,10 +8,9 @@ RSpec.describe 'Query.issue(id)' do ...@@ -8,10 +8,9 @@ RSpec.describe 'Query.issue(id)' do
let_it_be(:project) { create(:project) } let_it_be(:project) { create(:project) }
let_it_be(:issue) { create(:issue, project: project) } let_it_be(:issue) { create(:issue, project: project) }
let_it_be(:current_user) { create(:user) } let_it_be(:current_user) { create(:user) }
let_it_be(:issue_params) { { 'id' => issue.to_global_id.to_s } }
let(:issue_data) { graphql_data['issue'] } let(:issue_data) { graphql_data['issue'] }
let_it_be(:issue_params) { { 'id' => issue.to_global_id.to_s } }
let(:issue_fields) { all_graphql_fields_for('Issue'.classify) } let(:issue_fields) { all_graphql_fields_for('Issue'.classify) }
let(:query) do let(:query) do
...@@ -62,7 +61,7 @@ RSpec.describe 'Query.issue(id)' do ...@@ -62,7 +61,7 @@ RSpec.describe 'Query.issue(id)' do
) )
end end
context 'selecting any single field' do context 'when selecting any single field' do
where(:field) do where(:field) do
scalar_fields_of('Issue').map { |name| [name] } scalar_fields_of('Issue').map { |name| [name] }
end end
...@@ -84,13 +83,13 @@ RSpec.describe 'Query.issue(id)' do ...@@ -84,13 +83,13 @@ RSpec.describe 'Query.issue(id)' do
end end
end end
context 'selecting multiple fields' do context 'when selecting multiple fields' do
let(:issue_fields) { ['title', 'description', 'updatedBy { username }'] } let(:issue_fields) { ['title', 'description', 'updatedBy { username }'] }
it 'returns the Issue with the specified fields' do it 'returns the Issue with the specified fields' do
post_graphql(query, current_user: current_user) post_graphql(query, current_user: current_user)
expect(issue_data.keys).to eq( %w(title description updatedBy) ) expect(issue_data.keys).to eq %w[title description updatedBy]
expect(issue_data['title']).to eq(issue.title) expect(issue_data['title']).to eq(issue.title)
expect(issue_data['description']).to eq(issue.description) expect(issue_data['description']).to eq(issue.description)
expect(issue_data['updatedBy']['username']).to eq(issue.author.username) expect(issue_data['updatedBy']['username']).to eq(issue.author.username)
...@@ -110,14 +109,14 @@ RSpec.describe 'Query.issue(id)' do ...@@ -110,14 +109,14 @@ RSpec.describe 'Query.issue(id)' do
it 'returns correct attributes' do it 'returns correct attributes' do
post_graphql(query, current_user: current_user) post_graphql(query, current_user: current_user)
expect(issue_data.keys).to eq( %w(moved movedTo) ) expect(issue_data.keys).to eq %w[moved movedTo]
expect(issue_data['moved']).to eq(true) expect(issue_data['moved']).to eq(true)
expect(issue_data['movedTo']['title']).to eq(new_issue.title) expect(issue_data['movedTo']['title']).to eq(new_issue.title)
end end
end end
context 'when passed a non-Issue gid' do context 'when passed a non-Issue gid' do
let(:mr) {create(:merge_request)} let(:mr) { create(:merge_request) }
it 'returns an error' do it 'returns an error' do
gid = mr.to_global_id.to_s gid = mr.to_global_id.to_s
......
...@@ -38,7 +38,10 @@ module GraphqlHelpers ...@@ -38,7 +38,10 @@ module GraphqlHelpers
# All resolution goes through fields, so we need to create one here that # All resolution goes through fields, so we need to create one here that
# uses our resolver. Thankfully, apart from the field name, resolvers # uses our resolver. Thankfully, apart from the field name, resolvers
# contain all the configuration needed to define one. # contain all the configuration needed to define one.
field_options = resolver_class.field_options.merge(name: 'field_value') field_options = resolver_class.field_options.merge(
owner: resolver_parent,
name: 'field_value'
)
field = ::Types::BaseField.new(**field_options) field = ::Types::BaseField.new(**field_options)
# All mutations accept a single `:input` argument. Wrap arguments here. # All mutations accept a single `:input` argument. Wrap arguments here.
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment