Commit 5c62b8c8 authored by Eulyeon Ko's avatar Eulyeon Ko Committed by Alex Kalderimis

Add IssuesFieldExtension to set relative positions

IssuesFieldExtension is a field extension for
the field issues of the type BoardList.
The extension is used to check and set
relative positions of returned issues.

Because IssuesFieldExtension needs to pass
a paginated list of issues to
::Boards::Issues::ListService.initialize_relative_positions
for performance, the extension needs to execute after
the underlying issues relation has been wrapped
by the default pagination connection extension.
We achieve this by utilizing 'late_extensions'
on initializing the field.

Previously we incorrectly called initialize_relative_position
by passing an underlying AR relation rather than
a paginated result when resolving the field in BoardListIssuesResolver.
This produced DB queries without `LIMIT` clause -
causing performance degradations.

Changelog: fixed
parent 6bb96f42
...@@ -18,11 +18,8 @@ module Resolvers ...@@ -18,11 +18,8 @@ module Resolvers
filter_params = filters.merge(board_id: list.board.id, id: list.id) filter_params = filters.merge(board_id: list.board.id, id: list.id)
service = ::Boards::Issues::ListService.new(list.board.resource_parent, context[:current_user], filter_params) service = ::Boards::Issues::ListService.new(list.board.resource_parent, context[:current_user], filter_params)
pagination_connections = Gitlab::Graphql::Pagination::Keyset::Connection.new(service.execute)
::Boards::Issues::ListService.initialize_relative_positions(list.board, current_user, pagination_connections.items) service.execute
pagination_connections
end end
# https://gitlab.com/gitlab-org/gitlab/-/issues/235681 # https://gitlab.com/gitlab-org/gitlab/-/issues/235681
......
...@@ -21,6 +21,7 @@ module Types ...@@ -21,6 +21,7 @@ module Types
@feature_flag = kwargs[:feature_flag] @feature_flag = kwargs[:feature_flag]
kwargs = check_feature_flag(kwargs) kwargs = check_feature_flag(kwargs)
@deprecation = gitlab_deprecation(kwargs) @deprecation = gitlab_deprecation(kwargs)
after_connection_extensions = kwargs.delete(:late_extensions) || []
super(**kwargs, &block) super(**kwargs, &block)
...@@ -28,6 +29,8 @@ module Types ...@@ -28,6 +29,8 @@ module Types
extension ::Gitlab::Graphql::CallsGitaly::FieldExtension if Gitlab.dev_or_test_env? extension ::Gitlab::Graphql::CallsGitaly::FieldExtension if Gitlab.dev_or_test_env?
extension ::Gitlab::Graphql::Present::FieldExtension extension ::Gitlab::Graphql::Present::FieldExtension
extension ::Gitlab::Graphql::Authorize::ConnectionFilterExtension extension ::Gitlab::Graphql::Authorize::ConnectionFilterExtension
after_connection_extensions.each { extension _1 } if after_connection_extensions.any?
end end
def may_call_gitaly? def may_call_gitaly?
......
...@@ -27,6 +27,7 @@ module Types ...@@ -27,6 +27,7 @@ module Types
field :issues, ::Types::IssueType.connection_type, null: true, field :issues, ::Types::IssueType.connection_type, null: true,
description: 'Board issues.', description: 'Board issues.',
late_extensions: [Gitlab::Graphql::Board::IssuesConnectionExtension],
resolver: ::Resolvers::BoardListIssuesResolver resolver: ::Resolvers::BoardListIssuesResolver
def issues_count def issues_count
......
...@@ -134,6 +134,6 @@ RSpec.describe Resolvers::BoardListIssuesResolver do ...@@ -134,6 +134,6 @@ RSpec.describe Resolvers::BoardListIssuesResolver do
end end
def resolve_board_list_issues(args) def resolve_board_list_issues(args)
resolve(described_class, obj: list, args: args, ctx: { current_user: user }).items resolve(described_class, obj: list, args: args, ctx: { current_user: user })
end end
end end
# frozen_string_literal: true
module Gitlab
module Graphql
module Board
class IssuesConnectionExtension < GraphQL::Schema::Field::ConnectionExtension
def after_resolve(value:, object:, context:, **rest)
::Boards::Issues::ListService
.initialize_relative_positions(object.list.board, context[:current_user], value.nodes)
value
end
end
end
end
end
...@@ -6,7 +6,7 @@ module Gitlab ...@@ -6,7 +6,7 @@ module Gitlab
extend ActiveSupport::Concern extend ActiveSupport::Concern
included do included do
delegate :to_a, :size, :include?, :empty?, to: :nodes delegate :to_a, :size, :map, :include?, :empty?, to: :nodes
end end
end end
end end
......
...@@ -31,12 +31,11 @@ RSpec.describe Resolvers::BoardListIssuesResolver do ...@@ -31,12 +31,11 @@ RSpec.describe Resolvers::BoardListIssuesResolver do
end.to raise_error(Gitlab::Graphql::Errors::ArgumentError) end.to raise_error(Gitlab::Graphql::Errors::ArgumentError)
end end
it 'returns issues in the correct order with non-nil relative positions', :aggregate_failures do it 'returns the issues in the correct order' do
# by relative_position and then ID # by relative_position and then ID
result = resolve_board_list_issues result = resolve_board_list_issues
expect(result.map(&:id)).to eq [issue1.id, issue3.id, issue2.id, issue4.id] expect(result.map(&:id)).to eq [issue1.id, issue3.id, issue2.id, issue4.id]
expect(result.map(&:relative_position)).not_to include(nil)
end end
it 'finds only issues matching filters' do it 'finds only issues matching filters' do
...@@ -119,6 +118,6 @@ RSpec.describe Resolvers::BoardListIssuesResolver do ...@@ -119,6 +118,6 @@ RSpec.describe Resolvers::BoardListIssuesResolver do
end end
def resolve_board_list_issues(args: {}, current_user: user) def resolve_board_list_issues(args: {}, current_user: user)
resolve(described_class, obj: list, args: args, ctx: { current_user: current_user }).items resolve(described_class, obj: list, args: args, ctx: { current_user: current_user })
end end
end end
...@@ -154,6 +154,17 @@ RSpec.describe Types::BaseField do ...@@ -154,6 +154,17 @@ RSpec.describe Types::BaseField do
end end
end end
describe '#resolve' do
context "late_extensions is given" do
it 'registers the late extensions after the regular extensions' do
extension_class = Class.new(GraphQL::Schema::Field::ConnectionExtension)
field = described_class.new(name: 'test', type: GraphQL::Types::String.connection_type, null: true, late_extensions: [extension_class])
expect(field.extensions.last.class).to be(extension_class)
end
end
end
describe '#description' do describe '#description' do
context 'feature flag given' do context 'feature flag given' do
let(:field) { described_class.new(name: 'test', type: GraphQL::Types::String, feature_flag: flag, null: false, description: 'Test description.') } let(:field) { described_class.new(name: 'test', type: GraphQL::Types::String, feature_flag: flag, null: false, description: 'Test description.') }
......
...@@ -10,4 +10,12 @@ RSpec.describe GitlabSchema.types['BoardList'] do ...@@ -10,4 +10,12 @@ RSpec.describe GitlabSchema.types['BoardList'] do
expect(described_class).to include_graphql_fields(*expected_fields) expect(described_class).to include_graphql_fields(*expected_fields)
end end
describe 'issues field' do
subject { described_class.fields['issues'] }
it 'has a correct extension' do
is_expected.to have_graphql_extension(Gitlab::Graphql::Board::IssuesConnectionExtension)
end
end
end end
...@@ -30,7 +30,7 @@ RSpec.describe 'get board lists' do ...@@ -30,7 +30,7 @@ RSpec.describe 'get board lists' do
nodes { nodes {
lists { lists {
nodes { nodes {
issues(filters: {labelName: "#{label2.title}"}) { issues(filters: {labelName: "#{label2.title}"}, first: 3) {
count count
nodes { nodes {
#{all_graphql_fields_for('issues'.classify)} #{all_graphql_fields_for('issues'.classify)}
...@@ -44,6 +44,10 @@ RSpec.describe 'get board lists' do ...@@ -44,6 +44,10 @@ RSpec.describe 'get board lists' do
) )
end end
def issue_id
issues_data.map { |i| i['id'] }
end
def issue_titles def issue_titles
issues_data.map { |i| i['title'] } issues_data.map { |i| i['title'] }
end end
...@@ -60,6 +64,7 @@ RSpec.describe 'get board lists' do ...@@ -60,6 +64,7 @@ RSpec.describe 'get board lists' do
let!(:issue3) { create(:issue, project: issue_project, labels: [label, label2], relative_position: nil) } let!(:issue3) { create(:issue, project: issue_project, labels: [label, label2], relative_position: nil) }
let!(:issue4) { create(:issue, project: issue_project, labels: [label], relative_position: 9) } let!(:issue4) { create(:issue, project: issue_project, labels: [label], relative_position: 9) }
let!(:issue5) { create(:issue, project: issue_project, labels: [label2], relative_position: 432) } let!(:issue5) { create(:issue, project: issue_project, labels: [label2], relative_position: 432) }
let!(:issue6) { create(:issue, project: issue_project, labels: [label, label2], relative_position: nil) }
context 'when the user does not have access to the board' do context 'when the user does not have access to the board' do
it 'returns nil' do it 'returns nil' do
...@@ -72,14 +77,19 @@ RSpec.describe 'get board lists' do ...@@ -72,14 +77,19 @@ RSpec.describe 'get board lists' do
context 'when user can read the board' do context 'when user can read the board' do
before do before do
board_parent.add_reporter(user) board_parent.add_reporter(user)
post_graphql(query("id: \"#{global_id_of(label_list)}\""), current_user: user)
end end
it 'can access the issues', :aggregate_failures do it 'can access the issues', :aggregate_failures do
post_graphql(query("id: \"#{global_id_of(label_list)}\""), current_user: user) # ties for relative positions are broken by id in ascending order by default
expect(issue_titles).to eq([issue2.title, issue1.title, issue3.title]) expect(issue_titles).to eq([issue2.title, issue1.title, issue3.title])
expect(issue_relative_positions).not_to include(nil) expect(issue_relative_positions).not_to include(nil)
end end
it 'does not set the relative positions of the issues not being returned', :aggregate_failures do
expect(issue_id).not_to include(issue6.id)
expect(issue3.relative_position).to be_nil
end
end end
end end
......
# frozen_string_literal: true # frozen_string_literal: true
RSpec.shared_examples 'a connection with collection methods' do RSpec.shared_examples 'a connection with collection methods' do
%i[to_a size include? empty?].each do |method_name| %i[to_a size map include? empty?].each do |method_name|
it "responds to #{method_name}" do it "responds to #{method_name}" do
expect(connection).to respond_to(method_name) expect(connection).to respond_to(method_name)
end end
......
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