Commit 674f7bc5 authored by Jarka Košanová's avatar Jarka Košanová

Merge branch 'cablett-graphql-epic-n+1' into 'master'

Remove n+1 queries from GraphQL EpicType

See merge request gitlab-org/gitlab!27551
parents 92bf566b 389c0e8e
...@@ -79,11 +79,9 @@ module Types ...@@ -79,11 +79,9 @@ module Types
description: 'Labels assigned to the epic' description: 'Labels assigned to the epic'
field :has_children, GraphQL::BOOLEAN_TYPE, null: false, field :has_children, GraphQL::BOOLEAN_TYPE, null: false,
description: 'Indicates if the epic has children', description: 'Indicates if the epic has children'
method: :has_children?
field :has_issues, GraphQL::BOOLEAN_TYPE, null: false, field :has_issues, GraphQL::BOOLEAN_TYPE, null: false,
description: 'Indicates if the epic has direct issues', description: 'Indicates if the epic has direct issues'
method: :has_issues?
field :web_path, GraphQL::STRING_TYPE, null: false, field :web_path, GraphQL::STRING_TYPE, null: false,
description: 'Web path of the epic', description: 'Web path of the epic',
...@@ -145,5 +143,24 @@ module Types ...@@ -145,5 +143,24 @@ module Types
resolve: -> (epic, args, ctx) do resolve: -> (epic, args, ctx) do
Epics::DescendantCountService.new(epic, ctx[:current_user]) Epics::DescendantCountService.new(epic, ctx[:current_user])
end end
def has_children?
return object.has_children? unless Feature.enabled?(:unfiltered_epic_aggregates, object.group, default_enabled: true)
Gitlab::Graphql::Aggregations::Epics::LazyEpicAggregate.new(context, object.id, COUNT) do |node, _aggregate_object|
node.children.any?
end
end
def has_issues?
return object.has_issues? unless Feature.enabled?(:unfiltered_epic_aggregates, object.group, default_enabled: true)
Gitlab::Graphql::Aggregations::Epics::LazyEpicAggregate.new(context, object.id, COUNT) do |node, _aggregate_object|
node.has_issues?
end
end
alias_method :has_children, :has_children?
alias_method :has_issues, :has_issues?
end end
end end
...@@ -50,10 +50,10 @@ module Gitlab ...@@ -50,10 +50,10 @@ module Gitlab
def to_s def to_s
{ {
epic_id: @epic_id, epic_id: @epic_id,
parent_id: @parent_id, parent_id: @parent_id,
children: children, children: children,
object_id: object_id object_id: object_id
}.to_s }.to_s
end end
...@@ -70,6 +70,12 @@ module Gitlab ...@@ -70,6 +70,12 @@ module Gitlab
@sums[key] = direct_sum + sum_from_children @sums[key] = direct_sum + sum_from_children
end end
def has_issues?
[CLOSED_ISSUE_STATE, OPENED_ISSUE_STATE].any? do |state|
value_from_records(COUNT, state, ISSUE_TYPE) > 0
end
end
private private
def set_epic_attributes(record) def set_epic_attributes(record)
......
...@@ -13,7 +13,7 @@ module Gitlab ...@@ -13,7 +13,7 @@ module Gitlab
# Because facets "count" and "weight_sum" share the same db query, but have a different graphql type object, # Because facets "count" and "weight_sum" share the same db query, but have a different graphql type object,
# we can separate them and serve only the fields which are requested by the GraphQL query # we can separate them and serve only the fields which are requested by the GraphQL query
def initialize(query_ctx, epic_id, aggregate_facet) def initialize(query_ctx, epic_id, aggregate_facet, &block)
@epic_id = epic_id @epic_id = epic_id
error = validate_facet(aggregate_facet) error = validate_facet(aggregate_facet)
...@@ -31,6 +31,8 @@ module Gitlab ...@@ -31,6 +31,8 @@ module Gitlab
} }
# Register this ID to be loaded later: # Register this ID to be loaded later:
@lazy_state[:pending_ids] << epic_id @lazy_state[:pending_ids] << epic_id
@block = block
end end
# Return the loaded record, hitting the database if needed # Return the loaded record, hitting the database if needed
...@@ -41,7 +43,10 @@ module Gitlab ...@@ -41,7 +43,10 @@ module Gitlab
load_records_into_tree load_records_into_tree
end end
aggregate_object(tree[@epic_id]) node = tree[@epic_id]
object = aggregate_object(node)
@block ? @block.call(node, object) : object
end end
private private
......
...@@ -39,6 +39,12 @@ describe Gitlab::Graphql::Aggregations::Epics::EpicNode do ...@@ -39,6 +39,12 @@ describe Gitlab::Graphql::Aggregations::Epics::EpicNode do
allow(subject).to receive(:epic_info_flat_list).and_return(flat_info) allow(subject).to receive(:epic_info_flat_list).and_return(flat_info)
end end
shared_examples 'has_issues?' do |expected_result|
it "returns #{expected_result}" do
expect(subject.has_issues?).to eq expected_result
end
end
context 'an epic with no child epics' do context 'an epic with no child epics' do
context 'with no child issues', :aggregate_results do context 'with no child issues', :aggregate_results do
let(:flat_info) { [] } let(:flat_info) { [] }
...@@ -52,6 +58,8 @@ describe Gitlab::Graphql::Aggregations::Epics::EpicNode do ...@@ -52,6 +58,8 @@ describe Gitlab::Graphql::Aggregations::Epics::EpicNode do
expect(subject).to have_aggregate(EPIC_TYPE, COUNT, OPENED_EPIC_STATE, 0) expect(subject).to have_aggregate(EPIC_TYPE, COUNT, OPENED_EPIC_STATE, 0)
expect(subject).to have_aggregate(EPIC_TYPE, COUNT, CLOSED_EPIC_STATE, 0) expect(subject).to have_aggregate(EPIC_TYPE, COUNT, CLOSED_EPIC_STATE, 0)
end end
it_behaves_like 'has_issues?', false
end end
context 'with an issue with 0 weight', :aggregate_results do context 'with an issue with 0 weight', :aggregate_results do
...@@ -70,9 +78,11 @@ describe Gitlab::Graphql::Aggregations::Epics::EpicNode do ...@@ -70,9 +78,11 @@ describe Gitlab::Graphql::Aggregations::Epics::EpicNode do
expect(subject).to have_aggregate(EPIC_TYPE, COUNT, OPENED_EPIC_STATE, 0) expect(subject).to have_aggregate(EPIC_TYPE, COUNT, OPENED_EPIC_STATE, 0)
expect(subject).to have_aggregate(EPIC_TYPE, COUNT, CLOSED_EPIC_STATE, 0) expect(subject).to have_aggregate(EPIC_TYPE, COUNT, CLOSED_EPIC_STATE, 0)
end end
it_behaves_like 'has_issues?', true
end end
context 'with an issue with nonzero weight' do context 'with an open issue with nonzero weight' do
let(:flat_info) do let(:flat_info) do
[ [
record_for(epic_id: epic_id, parent_id: nil, epic_state_id: CLOSED_EPIC_STATE, issues_state_id: OPENED_ISSUE_STATE, issues_count: 1, issues_weight_sum: 2) record_for(epic_id: epic_id, parent_id: nil, epic_state_id: CLOSED_EPIC_STATE, issues_state_id: OPENED_ISSUE_STATE, issues_count: 1, issues_weight_sum: 2)
...@@ -88,6 +98,18 @@ describe Gitlab::Graphql::Aggregations::Epics::EpicNode do ...@@ -88,6 +98,18 @@ describe Gitlab::Graphql::Aggregations::Epics::EpicNode do
expect(subject).to have_aggregate(EPIC_TYPE, COUNT, OPENED_EPIC_STATE, 0) expect(subject).to have_aggregate(EPIC_TYPE, COUNT, OPENED_EPIC_STATE, 0)
expect(subject).to have_aggregate(EPIC_TYPE, COUNT, CLOSED_EPIC_STATE, 0) expect(subject).to have_aggregate(EPIC_TYPE, COUNT, CLOSED_EPIC_STATE, 0)
end end
it_behaves_like 'has_issues?', true
end
context 'with a closed issue with nonzero weight' do
let(:flat_info) do
[
record_for(epic_id: epic_id, parent_id: nil, epic_state_id: CLOSED_EPIC_STATE, issues_state_id: CLOSED_ISSUE_STATE, issues_count: 1, issues_weight_sum: 2)
]
end
it_behaves_like 'has_issues?', true
end end
end end
...@@ -119,6 +141,8 @@ describe Gitlab::Graphql::Aggregations::Epics::EpicNode do ...@@ -119,6 +141,8 @@ describe Gitlab::Graphql::Aggregations::Epics::EpicNode do
expect(subject).to have_aggregate(EPIC_TYPE, COUNT, OPENED_EPIC_STATE, 1) expect(subject).to have_aggregate(EPIC_TYPE, COUNT, OPENED_EPIC_STATE, 1)
expect(subject).to have_aggregate(EPIC_TYPE, COUNT, CLOSED_EPIC_STATE, 0) expect(subject).to have_aggregate(EPIC_TYPE, COUNT, CLOSED_EPIC_STATE, 0)
end end
it_behaves_like 'has_issues?', false
end end
end end
end end
......
...@@ -92,6 +92,20 @@ describe Gitlab::Graphql::Aggregations::Epics::LazyEpicAggregate do ...@@ -92,6 +92,20 @@ describe Gitlab::Graphql::Aggregations::Epics::LazyEpicAggregate do
expect(lazy_state[:pending_ids]).to be_empty expect(lazy_state[:pending_ids]).to be_empty
end end
context 'if a block is provided' do
let(:result) { 123 }
subject do
described_class.new(query_ctx, epic_id, COUNT) do |object|
result
end
end
it 'calls the block' do
expect(subject.epic_aggregate).to eq result
end
end
it 'creates the parent-child associations', :aggregate_failures do it 'creates the parent-child associations', :aggregate_failures do
subject.epic_aggregate subject.epic_aggregate
......
...@@ -7,26 +7,26 @@ describe 'Epic aggregates (count and weight)' do ...@@ -7,26 +7,26 @@ describe 'Epic aggregates (count and weight)' do
let_it_be(:current_user) { create(:user) } let_it_be(:current_user) { create(:user) }
let_it_be(:group) { create(:group, :public) } let_it_be(:group) { create(:group, :public) }
let_it_be(:parent_epic) { create(:epic, id: 1, group: group, title: 'parent epic') } let_it_be(:parent_epic) { create(:epic, group: group, title: 'parent epic') }
let(:target_epic) { parent_epic }
let(:query) do let(:query) do
graphql_query_for('group', { fullPath: group.full_path }, query_graphql_field('epics', { iid: parent_epic.iid }, epic_aggregates_query)) graphql_query_for('group', { fullPath: target_epic.group.full_path }, query_graphql_field('epics', { iid: target_epic.iid }, epic_aggregates_query))
end end
let(:epic_aggregates_query) do let(:epic_aggregates_query) do
<<~QUERY <<~QUERY
nodes { nodes {
descendantWeightSum { descendantWeightSum {
openedIssues openedIssues
closedIssues closedIssues
} }
descendantCounts { descendantCounts {
openedEpics openedEpics
closedEpics closedEpics
openedIssues openedIssues
closedIssues closedIssues
}
} }
}
QUERY QUERY
end end
...@@ -42,15 +42,17 @@ describe 'Epic aggregates (count and weight)' do ...@@ -42,15 +42,17 @@ describe 'Epic aggregates (count and weight)' do
let_it_be(:project) { create(:project, namespace: group) } let_it_be(:project) { create(:project, namespace: group) }
let_it_be(:epic_with_issues) { create(:epic, id: 2, group: subgroup, parent: parent_epic, title: 'epic with issues') } let_it_be(:epic_with_issues) { create(:epic, group: subgroup, parent: parent_epic, title: 'epic with issues') }
let_it_be(:epic_without_issues) { create(:epic, :closed, id: 3, group: subgroup, parent: parent_epic, title: 'epic without issues') } let_it_be(:epic_without_issues) { create(:epic, :closed, group: subgroup, parent: parent_epic, title: 'epic without issues') }
let_it_be(:closed_epic) { create(:epic, :closed, id: 4, group: subgroup, parent: parent_epic, title: 'closed epic') } let_it_be(:closed_epic) { create(:epic, :closed, group: subgroup, parent: parent_epic, title: 'closed epic') }
let_it_be(:issue1) { create(:issue, project: project, weight: 5, state: :opened) } let_it_be(:issue1) { create(:issue, project: project, weight: 5, state: :opened) }
let_it_be(:issue2) { create(:issue, project: project, weight: 7, state: :closed) } let_it_be(:issue2) { create(:issue, project: project, weight: 7, state: :closed) }
let_it_be(:issue3) { create(:issue, project: project, weight: 0) }
let_it_be(:epic_issue1) { create(:epic_issue, epic: epic_with_issues, issue: issue1) } let_it_be(:epic_issue1) { create(:epic_issue, epic: epic_with_issues, issue: issue1) }
let_it_be(:epic_issue2) { create(:epic_issue, epic: epic_with_issues, issue: issue2) } let_it_be(:epic_issue2) { create(:epic_issue, epic: epic_with_issues, issue: issue2) }
let_it_be(:epic_issue3) { create(:epic_issue, epic: parent_epic, issue: issue3) }
before do before do
group.add_developer(current_user) group.add_developer(current_user)
...@@ -73,7 +75,7 @@ describe 'Epic aggregates (count and weight)' do ...@@ -73,7 +75,7 @@ describe 'Epic aggregates (count and weight)' do
it 'returns the issue counts' do it 'returns the issue counts' do
issue_count_result = { issue_count_result = {
"openedIssues" => 1, "openedIssues" => 2,
"closedIssues" => 1 "closedIssues" => 1
} }
...@@ -83,6 +85,53 @@ describe 'Epic aggregates (count and weight)' do ...@@ -83,6 +85,53 @@ describe 'Epic aggregates (count and weight)' do
end end
end end
shared_examples 'having correct values for' do |field_name|
let(:epic_aggregates_query) do
<<~QUERY
nodes {
#{field_name}
}
QUERY
end
it_behaves_like 'a working graphql query'
context 'when target epic has child epics or issues' do
let(:target_epic) { parent_epic }
it 'returns true' do
post_graphql(query, current_user: current_user)
expect(subject).to include(a_hash_including(field_name => true))
end
end
context 'when target epic has no child epics nor issues' do
let(:target_epic) { epic_without_issues }
it 'returns false' do
post_graphql(query, current_user: current_user)
expect(subject).to include(a_hash_including(field_name => false))
end
end
end
shared_examples 'efficient query' do
it 'does not result in N+1' do
control_count = ActiveRecord::QueryRecorder.new { post_graphql(query, current_user: current_user) }.count
query_with_multiple_epics = graphql_query_for('group', { fullPath: epic_with_issues.group.full_path }, query_graphql_field('epics', { iids: [epic_with_issues.iid, epic_without_issues.iid, parent_epic.iid] }, epic_aggregates_query))
# We still get multiple of these lines
# Group Load (0.6ms) SELECT "namespaces".* FROM "namespaces" WHERE "namespaces"."type" = 'Group' AND "namespaces"."id" = x LIMIT 1
# -> ee/app/policies/epic_policy.rb:4:in `block in <class:EpicPolicy>'
# So I'll add n to this number to take this into account.
# See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/27551#note_307716428
expect { post_graphql(query_with_multiple_epics, current_user: current_user) }.not_to exceed_query_limit(control_count + 2)
end
end
context 'with feature flag enabled' do context 'with feature flag enabled' do
before do before do
stub_feature_flags(unfiltered_epic_aggregates: true) stub_feature_flags(unfiltered_epic_aggregates: true)
...@@ -99,14 +148,40 @@ describe 'Epic aggregates (count and weight)' do ...@@ -99,14 +148,40 @@ describe 'Epic aggregates (count and weight)' do
it 'returns the weights' do it 'returns the weights' do
descendant_weight_result = { descendant_weight_result = {
"openedIssues" => 5, "openedIssues" => 5,
"closedIssues" => 7 "closedIssues" => 7
} }
is_expected.to include( is_expected.to include(
a_hash_including('descendantWeightSum' => a_hash_including(descendant_weight_result)) a_hash_including('descendantWeightSum' => a_hash_including(descendant_weight_result))
) )
end end
context 'when requesting has_issues' do
let(:epic_aggregates_query) do
<<~QUERY
nodes {
hasIssues
}
QUERY
end
it_behaves_like 'having correct values for', 'hasIssues'
it_behaves_like 'efficient query'
end
context 'when requesting has_children' do
let(:epic_aggregates_query) do
<<~QUERY
nodes {
hasChildren
}
QUERY
end
it_behaves_like 'having correct values for', 'hasChildren'
it_behaves_like 'efficient query'
end
end end
context 'with feature flag disabled' do context 'with feature flag disabled' do
...@@ -114,17 +189,20 @@ describe 'Epic aggregates (count and weight)' do ...@@ -114,17 +189,20 @@ describe 'Epic aggregates (count and weight)' do
stub_feature_flags(unfiltered_epic_aggregates: false) stub_feature_flags(unfiltered_epic_aggregates: false)
end end
it_behaves_like 'having correct values for', 'hasChildren'
it_behaves_like 'having correct values for', 'hasIssues'
context 'when requesting counts' do context 'when requesting counts' do
let(:epic_aggregates_query) do let(:epic_aggregates_query) do
<<~QUERY <<~QUERY
nodes { nodes {
descendantCounts { descendantCounts {
openedEpics openedEpics
closedEpics closedEpics
openedIssues openedIssues
closedIssues closedIssues
}
} }
}
QUERY QUERY
end end
...@@ -140,12 +218,12 @@ describe 'Epic aggregates (count and weight)' do ...@@ -140,12 +218,12 @@ describe 'Epic aggregates (count and weight)' do
context 'when requesting weights' do context 'when requesting weights' do
let(:epic_aggregates_query) do let(:epic_aggregates_query) do
<<~QUERY <<~QUERY
nodes { nodes {
descendantWeightSum { descendantWeightSum {
openedIssues openedIssues
closedIssues closedIssues
}
} }
}
QUERY QUERY
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