Commit 920c4e70 authored by charlieablett's avatar charlieablett

Move aggregate logic

Move from `app/graphql`
to `lib/gitlab/graphql/aggregations/epics`
parent 64ae89da
......@@ -5,7 +5,7 @@ module EE
extend ActiveSupport::Concern
prepended do
lazy_resolve ::Epics::LazyEpicAggregate, :epic_aggregate
lazy_resolve ::Gitlab::Graphql::Aggregations::Epics::LazyEpicAggregate, :epic_aggregate
end
end
end
# frozen_string_literal: true
module Epics
class Aggregate
include AggregateConstants
def initialize(values)
raise NotImplementedError.new('Use either CountAggregate or WeightSumAggregate')
end
private
def sum_objects(state, type)
matching = @sums.select { |sum| sum.state == state && sum.type == type && sum.facet == facet}
return 0 if @sums.empty?
matching.map(&:value).reduce(:+) || 0
end
def facet
raise NotImplementedError.new('Use either CountAggregate or WeightSumAggregate')
end
end
end
# frozen_string_literal: true
module Epics
module AggregateConstants
ISSUE_TYPE = :issue
EPIC_TYPE = :epic
CLOSED_ISSUE_STATE = Issue.available_states[:closed].freeze
OPENED_ISSUE_STATE = Issue.available_states[:opened].freeze
CLOSED_EPIC_STATE = Epic.available_states[:closed].freeze
OPENED_EPIC_STATE = Epic.available_states[:opened].freeze
COUNT = :count
WEIGHT_SUM = :weight_sum
end
end
# frozen_string_literal: true
module Epics
class CountAggregate < Aggregate
attr_accessor :opened_issues, :closed_issues, :opened_epics, :closed_epics
def initialize(sums)
@sums = sums
@opened_issues = sum_objects(OPENED_ISSUE_STATE, ISSUE_TYPE)
@closed_issues = sum_objects(CLOSED_ISSUE_STATE, ISSUE_TYPE)
@opened_epics = sum_objects(OPENED_EPIC_STATE, EPIC_TYPE)
@closed_epics = sum_objects(CLOSED_EPIC_STATE, EPIC_TYPE)
end
def facet
COUNT
end
end
end
# frozen_string_literal: true
# This class represents an Epic's aggregate information (added up counts) about its child epics and direct issues
module Epics
class EpicNode
include AggregateConstants
attr_reader :epic_id, :epic_state_id, :epic_info_flat_list, :parent_id,
:direct_sums, # we calculate these and recursively add them
:sum_total
attr_accessor :child_ids
def initialize(epic_id, flat_info_list)
# epic aggregate records from the DB loader look like the following:
# { 1 => [{iid: 1, epic_state_id: 1, issues_count: 1, issues_weight_sum: 2, parent_id: nil, state_id: 2}] ... }
# They include the sum of each epic's direct issues, grouped by status,
# so in order to get a sum of the entire tree, we have to add that up recursively
@epic_id = epic_id
@epic_info_flat_list = flat_info_list
@child_ids = []
@direct_sums = []
set_epic_attributes(flat_info_list.first) # there will always be one
end
def assemble_issue_sums
# this is a representation of the epic's
# direct child issues and epics that have come from the DB
[OPENED_ISSUE_STATE, CLOSED_ISSUE_STATE].each do |issue_state|
matching_issue_state_entry = epic_info_flat_list.find do |epic_info_node|
epic_info_node[:issues_state_id] == issue_state
end || {}
create_sum_if_needed(WEIGHT_SUM, issue_state, ISSUE_TYPE, matching_issue_state_entry.fetch(:issues_weight_sum, 0))
create_sum_if_needed(COUNT, issue_state, ISSUE_TYPE, matching_issue_state_entry.fetch(:issues_count, 0))
end
end
def assemble_epic_sums(children)
[OPENED_EPIC_STATE, CLOSED_EPIC_STATE].each do |epic_state|
create_sum_if_needed(COUNT, epic_state, EPIC_TYPE, children.select { |node| node.epic_state_id == epic_state }.count)
end
end
def calculate_recursive_sums(tree)
return sum_total if sum_total
@sum_total = SumTotal.new
child_ids.each do |child_id|
child = tree[child_id]
# get the child's totals, add to your own
child_sums = child.calculate_recursive_sums(tree).sums
sum_total.add(child_sums)
end
sum_total.add(direct_sums)
sum_total
end
def aggregate_object_by(facet)
sum_total.by_facet(facet)
end
def to_s
{ epic_id: @epic_id, parent_id: @parent_id, direct_sums: direct_sums, child_ids: child_ids }.to_json
end
alias_method :inspect, :to_s
alias_method :id, :epic_id
private
def create_sum_if_needed(facet, state, type, value)
return if value.nil? || value < 1
direct_sums << Sum.new(facet, state, type, value)
end
def set_epic_attributes(record)
@epic_state_id = record[:epic_state_id]
@parent_id = record[:parent_id]
end
Sum = Struct.new(:facet, :state, :type, :value) do
def inspect
"<Sum facet=#{facet}, state=#{state}, type=#{type}, value=#{value}>"
end
end
end
end
# frozen_string_literal: true
module Epics
class LazyEpicAggregate
include AggregateConstants
attr_reader :facet, :epic_id, :lazy_state
# 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
def initialize(query_ctx, epic_id, aggregate_facet)
@epic_id = epic_id
raise ArgumentError.new("No aggregate facet provided. Please specify either #{COUNT} or #{WEIGHT_SUM}") unless aggregate_facet.present?
raise ArgumentError.new("Invalid aggregate facet #{aggregate_facet} provided. Please specify either #{COUNT} or #{WEIGHT_SUM}") unless [COUNT, WEIGHT_SUM].include?(aggregate_facet.to_sym)
@facet = aggregate_facet.to_sym
# Initialize the loading state for this query,
# or get the previously-initiated state
@lazy_state = query_ctx[:lazy_epic_aggregate] ||= {
pending_ids: Set.new,
tree: {}
}
# Register this ID to be loaded later:
@lazy_state[:pending_ids] << epic_id
end
# Return the loaded record, hitting the database if needed
def epic_aggregate
# Check if the record was already loaded:
# load from tree by epic
loaded_epic_info_node = @lazy_state[:tree][@epic_id]
if loaded_epic_info_node
# The pending IDs were already loaded,
# so return the result of that previous load
loaded_epic_info_node.aggregate_object_by(@facet)
else
load_records_into_tree
end
end
private
def tree
@lazy_state[:tree]
end
def load_records_into_tree
# The record hasn't been loaded yet, so
# hit the database with all pending IDs
pending_ids = @lazy_state[:pending_ids].to_a
# Fire off the db query and get the results (grouped by epic_id and facet)
raw_epic_aggregates = Epics::BulkEpicAggregateLoader.new(epic_ids: pending_ids).execute
# Assemble the tree and sum everything
create_structure_from(raw_epic_aggregates)
@lazy_state[:pending_ids].clear
# Now, get the matching node and return its aggregate depending on the facet:
epic_node = @lazy_state[:tree][@epic_id]
epic_node.aggregate_object_by(@facet)
end
def create_structure_from(aggregate_records)
# create EpicNode object for each epic id
aggregate_records.each do |epic_id, aggregates|
next if aggregates.nil? || aggregates.empty?
new_node = EpicNode.new(epic_id, aggregates)
tree[epic_id] = new_node
end
associate_parents_and_children
assemble_direct_sums
calculate_recursive_sums
end
# each of the methods below are done one after the other
def associate_parents_and_children
tree.each do |epic_id, node|
node.child_ids = tree.select { |_, child_node| epic_id == child_node.parent_id }.keys
end
end
def assemble_direct_sums
tree.each do |_, node|
node.assemble_issue_sums
node_children = tree.select { |_, child_node| node.epic_id == child_node.parent_id }.values
node.assemble_epic_sums(node_children)
end
end
def calculate_recursive_sums
tree.each do |_, node|
node.calculate_recursive_sums(tree)
end
end
end
end
# frozen_string_literal: true
module Epics
class SumTotal
include AggregateConstants
attr_accessor :sums
def initialize
@sums = []
end
def add(other_sums)
sums.concat(other_sums)
end
def by_facet(facet)
return ::Epics::CountAggregate.new(sums) if facet == COUNT
::Epics::WeightSumAggregate.new(sums)
end
end
end
# frozen_string_literal: true
module Epics
class WeightSumAggregate < Aggregate
attr_accessor :opened_issues, :closed_issues
def initialize(sums)
@sums = sums
@opened_issues = sum_objects(OPENED_ISSUE_STATE, :issue)
@closed_issues = sum_objects(CLOSED_ISSUE_STATE, :issue)
end
def facet
WEIGHT_SUM
end
end
end
# frozen_string_literal: true
module Epics
class BulkEpicAggregateLoader
include AggregateConstants
# This class retrieves each epic and its child epics recursively
# It allows us to recreate the epic tree structure in POROs
def initialize(epic_ids:)
@results = {}
@target_epic_ids = epic_ids.present? ? [epic_ids].flatten.compact : []
end
# rubocop: disable CodeReuse/ActiveRecord
def execute
return {} unless target_epic_ids.any?
# We do a left outer join in order to capture epics with no issues
# This is so we can aggregate the epic counts for every epic
raw_results = ::Gitlab::ObjectHierarchy.new(Epic.where(id: target_epic_ids)).base_and_descendants
.left_joins(epic_issues: :issue)
.group("issues.state_id", "epics.id", "epics.iid", "epics.parent_id", "epics.state_id")
.select("epics.id, epics.iid, epics.parent_id, epics.state_id AS epic_state_id, issues.state_id AS issues_state_id, COUNT(issues) AS issues_count, SUM(COALESCE(issues.weight, 0)) AS issues_weight_sum")
raw_results = raw_results.map(&:attributes).map(&:with_indifferent_access)
group_by_epic_id(raw_results)
@results
end
# rubocop: enable CodeReuse/ActiveRecord
private
attr_reader :target_epic_ids, :results
def group_by_epic_id(raw_records)
# for each id, populate with matching records
# change from a series of { id: x ... } to { x => [{...}, {...}] }
raw_records.map { |r| r[:id] }.uniq.each do |epic_id|
records = []
matching_records = raw_records.select { |record| record[:id] == epic_id }
matching_records.each do |record|
records << record.except(:id).to_h.with_indifferent_access
end
@results[epic_id] = records
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Graphql
module Aggregations
module Epics
class Aggregate
include Constants
def initialize(values)
raise NotImplementedError.new('Use either CountAggregate or WeightSumAggregate')
end
private
def sum_objects(state, type)
matching = @sums.select { |sum| sum.state == state && sum.type == type && sum.facet == facet}
return 0 if @sums.empty?
matching.map(&:value).reduce(:+) || 0
end
def facet
raise NotImplementedError.new('Use either CountAggregate or WeightSumAggregate')
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Graphql
module Aggregations
module Epics
module Constants
ISSUE_TYPE = :issue
EPIC_TYPE = :epic
CLOSED_ISSUE_STATE = Issue.available_states[:closed].freeze
OPENED_ISSUE_STATE = Issue.available_states[:opened].freeze
CLOSED_EPIC_STATE = Epic.available_states[:closed].freeze
OPENED_EPIC_STATE = Epic.available_states[:opened].freeze
COUNT = :count
WEIGHT_SUM = :weight_sum
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Graphql
module Aggregations
module Epics
class CountAggregate < Aggregate
attr_accessor :opened_issues, :closed_issues, :opened_epics, :closed_epics
def initialize(sums)
@sums = sums
@opened_issues = sum_objects(OPENED_ISSUE_STATE, ISSUE_TYPE)
@closed_issues = sum_objects(CLOSED_ISSUE_STATE, ISSUE_TYPE)
@opened_epics = sum_objects(OPENED_EPIC_STATE, EPIC_TYPE)
@closed_epics = sum_objects(CLOSED_EPIC_STATE, EPIC_TYPE)
end
def facet
COUNT
end
end
end
end
end
end
# frozen_string_literal: true
# This class represents an Epic's aggregate information (added up counts) about its child epics and direct issues
module Gitlab
module Graphql
module Aggregations
module Epics
class EpicNode
include Constants
attr_reader :epic_id, :epic_state_id, :epic_info_flat_list, :parent_id,
:direct_sums, # we calculate these and recursively add them
:sum_total
attr_accessor :child_ids
def initialize(epic_id, flat_info_list)
# epic aggregate records from the DB loader look like the following:
# { 1 => [{iid: 1, epic_state_id: 1, issues_count: 1, issues_weight_sum: 2, parent_id: nil, state_id: 2}] ... }
# They include the sum of each epic's direct issues, grouped by status,
# so in order to get a sum of the entire tree, we have to add that up recursively
@epic_id = epic_id
@epic_info_flat_list = flat_info_list
@child_ids = []
@direct_sums = []
set_epic_attributes(flat_info_list.first) # there will always be one
end
def assemble_issue_sums
# this is a representation of the epic's
# direct child issues and epics that have come from the DB
[OPENED_ISSUE_STATE, CLOSED_ISSUE_STATE].each do |issue_state|
matching_issue_state_entry = epic_info_flat_list.find do |epic_info_node|
epic_info_node[:issues_state_id] == issue_state
end || {}
create_sum_if_needed(WEIGHT_SUM, issue_state, ISSUE_TYPE, matching_issue_state_entry.fetch(:issues_weight_sum, 0))
create_sum_if_needed(COUNT, issue_state, ISSUE_TYPE, matching_issue_state_entry.fetch(:issues_count, 0))
end
end
def assemble_epic_sums(children)
[OPENED_EPIC_STATE, CLOSED_EPIC_STATE].each do |epic_state|
create_sum_if_needed(COUNT, epic_state, EPIC_TYPE, children.select { |node| node.epic_state_id == epic_state }.count)
end
end
def calculate_recursive_sums(tree)
return sum_total if sum_total
@sum_total = SumTotal.new
child_ids.each do |child_id|
child = tree[child_id]
# get the child's totals, add to your own
child_sums = child.calculate_recursive_sums(tree).sums
sum_total.add(child_sums)
end
sum_total.add(direct_sums)
sum_total
end
def aggregate_object_by(facet)
sum_total.by_facet(facet)
end
def to_s
{ epic_id: @epic_id, parent_id: @parent_id, direct_sums: direct_sums, child_ids: child_ids }.to_json
end
alias_method :inspect, :to_s
alias_method :id, :epic_id
private
def create_sum_if_needed(facet, state, type, value)
return if value.nil? || value < 1
direct_sums << Sum.new(facet, state, type, value)
end
def set_epic_attributes(record)
@epic_state_id = record[:epic_state_id]
@parent_id = record[:parent_id]
end
Sum = Struct.new(:facet, :state, :type, :value) do
def inspect
"<Sum facet=#{facet}, state=#{state}, type=#{type}, value=#{value}>"
end
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Graphql
module Aggregations
module Epics
class LazyEpicAggregate
include Constants
attr_reader :facet, :epic_id, :lazy_state
# 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
def initialize(query_ctx, epic_id, aggregate_facet)
@epic_id = epic_id
raise ArgumentError.new("No aggregate facet provided. Please specify either #{COUNT} or #{WEIGHT_SUM}") unless aggregate_facet.present?
raise ArgumentError.new("Invalid aggregate facet #{aggregate_facet} provided. Please specify either #{COUNT} or #{WEIGHT_SUM}") unless [COUNT, WEIGHT_SUM].include?(aggregate_facet.to_sym)
@facet = aggregate_facet.to_sym
# Initialize the loading state for this query,
# or get the previously-initiated state
@lazy_state = query_ctx[:lazy_epic_aggregate] ||= {
pending_ids: Set.new,
tree: {}
}
# Register this ID to be loaded later:
@lazy_state[:pending_ids] << epic_id
end
# Return the loaded record, hitting the database if needed
def epic_aggregate
# Check if the record was already loaded:
# load from tree by epic
loaded_epic_info_node = @lazy_state[:tree][@epic_id]
if loaded_epic_info_node
# The pending IDs were already loaded,
# so return the result of that previous load
loaded_epic_info_node.aggregate_object_by(@facet)
else
load_records_into_tree
end
end
private
def tree
@lazy_state[:tree]
end
def load_records_into_tree
# The record hasn't been loaded yet, so
# hit the database with all pending IDs
pending_ids = @lazy_state[:pending_ids].to_a
# Fire off the db query and get the results (grouped by epic_id and facet)
raw_epic_aggregates = Epics::BulkEpicAggregateLoader.new(epic_ids: pending_ids).execute
# Assemble the tree and sum everything
create_structure_from(raw_epic_aggregates)
@lazy_state[:pending_ids].clear
# Now, get the matching node and return its aggregate depending on the facet:
epic_node = @lazy_state[:tree][@epic_id]
epic_node.aggregate_object_by(@facet)
end
def create_structure_from(aggregate_records)
# create EpicNode object for each epic id
aggregate_records.each do |epic_id, aggregates|
next if aggregates.nil? || aggregates.empty?
new_node = EpicNode.new(epic_id, aggregates)
tree[epic_id] = new_node
end
associate_parents_and_children
assemble_direct_sums
calculate_recursive_sums
end
# each of the methods below are done one after the other
def associate_parents_and_children
tree.each do |epic_id, node|
node.child_ids = tree.select { |_, child_node| epic_id == child_node.parent_id }.keys
end
end
def assemble_direct_sums
tree.each do |_, node|
node.assemble_issue_sums
node_children = tree.select { |_, child_node| node.epic_id == child_node.parent_id }.values
node.assemble_epic_sums(node_children)
end
end
def calculate_recursive_sums
tree.each do |_, node|
node.calculate_recursive_sums(tree)
end
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Graphql
module Aggregations
module Epics
class SumTotal
include Constants
attr_accessor :sums
def initialize
@sums = []
end
def add(other_sums)
sums.concat(other_sums)
end
def by_facet(facet)
return CountAggregate.new(sums) if facet == COUNT
WeightSumAggregate.new(sums)
end
end
end
end
end
end
# frozen_string_literal: true
module Gitlab
module Graphql
module Aggregations
module Epics
class WeightSumAggregate < Aggregate
attr_accessor :opened_issues, :closed_issues
def initialize(sums)
@sums = sums
@opened_issues = sum_objects(OPENED_ISSUE_STATE, :issue)
@closed_issues = sum_objects(CLOSED_ISSUE_STATE, :issue)
end
def facet
WEIGHT_SUM
end
end
end
end
end
end
......@@ -2,26 +2,16 @@
require 'spec_helper'
describe 'Query epic aggregates (count and weight)' do
describe 'Epic aggregates (count and weight)' do
include GraphqlHelpers
let_it_be(:current_user) { create(:user) }
let_it_be(:group) { create(:group, :public) }
let_it_be(:subgroup) { create(:group, :private, parent: group)}
let_it_be(:subsubgroup) { create(:group, :private, parent: subgroup)}
let_it_be(:project) { create(:project, namespace: group) }
let_it_be(:parent_epic) { create(:epic, id: 1, group: group, title: 'parent epic') }
let_it_be(:epic_with_issues) { create(:epic, id: 2, 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(:closed_epic) { create(:epic, :closed, id: 4, group: subgroup, parent: parent_epic, title: 'closed epic') }
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(: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(:query) do
graphql_query_for('group', { fullPath: group.full_path }, query_graphql_field('epics', { iid: parent_epic.iid }, epic_aggregates_query))
end
let(:epic_aggregates_query) do
<<~QUERY
......@@ -40,18 +30,34 @@ describe 'Query epic aggregates (count and weight)' do
QUERY
end
let(:query) do
graphql_query_for('group', { fullPath: group.full_path }, query_graphql_field('epics', { iid: parent_epic.iid }, epic_aggregates_query))
before do
stub_licensed_features(epics: true)
end
context 'count and weight totals' do
subject { graphql_data.dig('group', 'epics', 'nodes') }
let_it_be(:subgroup) { create(:group, :private, parent: group)}
let_it_be(:subsubgroup) { create(:group, :private, parent: subgroup)}
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_without_issues) { create(:epic, :closed, id: 3, 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(:issue1) { create(:issue, project: project, weight: 5, state: :opened) }
let_it_be(:issue2) { create(:issue, project: project, weight: 7, state: :closed) }
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) }
before do
group.add_developer(current_user)
stub_licensed_features(epics: true)
post_graphql(query, current_user: current_user)
end
shared_examples 'counts properly' do
it_behaves_like 'a working graphql query'
it 'returns the epic counts' do
......@@ -75,6 +81,21 @@ describe 'Query epic aggregates (count and weight)' do
a_hash_including('descendantCounts' => a_hash_including(issue_count_result))
)
end
end
context 'with feature flag enabled' do
before do
stub_feature_flags(unfiltered_epic_aggregates: true)
end
it 'uses the LazyEpicAggregate service' do
# one for count, one for weight_sum, even though the share the same tree state as part of the context
expect(Epics::LazyEpicAggregate).to receive(:new).twice
post_graphql(query, current_user: current_user)
end
it_behaves_like 'counts properly'
it 'returns the weights' do
descendant_weight_result = {
......@@ -86,4 +107,53 @@ describe 'Query epic aggregates (count and weight)' do
a_hash_including('descendantWeightSum' => a_hash_including(descendant_weight_result))
)
end
end
context 'with feature flag disabled' do
before do
stub_feature_flags(unfiltered_epic_aggregates: false)
end
context 'when requesting counts' do
let(:epic_aggregates_query) do
<<~QUERY
nodes {
descendantCounts {
openedEpics
closedEpics
openedIssues
closedIssues
}
}
QUERY
end
it 'uses the DescendantCountService' do
expect(Epics::DescendantCountService).to receive(:new)
post_graphql(query, current_user: current_user)
end
it_behaves_like 'counts properly'
end
context 'when requesting weights' do
let(:epic_aggregates_query) do
<<~QUERY
nodes {
descendantWeightSum {
openedIssues
closedIssues
}
}
QUERY
end
it 'returns an error' do
post_graphql(query, current_user: current_user)
expect_graphql_errors_to_include /Field 'descendantWeightSum' doesn't exist on type 'Epic/
end
end
end
end
# frozen_string_literal: true
shared_context 'includes EpicAggregate constants' do
EPIC_TYPE = Epics::AggregateConstants::EPIC_TYPE
ISSUE_TYPE = Epics::AggregateConstants::ISSUE_TYPE
EPIC_TYPE = Gitlab::Graphql::Aggregations::Epics::Constants::EPIC_TYPE
ISSUE_TYPE = Gitlab::Graphql::Aggregations::Epics::Constants::ISSUE_TYPE
OPENED_EPIC_STATE = Epics::AggregateConstants::OPENED_EPIC_STATE
CLOSED_EPIC_STATE = Epics::AggregateConstants::CLOSED_EPIC_STATE
OPENED_ISSUE_STATE = Epics::AggregateConstants::OPENED_ISSUE_STATE
CLOSED_ISSUE_STATE = Epics::AggregateConstants::CLOSED_ISSUE_STATE
OPENED_EPIC_STATE = Gitlab::Graphql::Aggregations::Epics::Constants::OPENED_EPIC_STATE
CLOSED_EPIC_STATE = Gitlab::Graphql::Aggregations::Epics::Constants::CLOSED_EPIC_STATE
OPENED_ISSUE_STATE = Gitlab::Graphql::Aggregations::Epics::Constants::OPENED_ISSUE_STATE
CLOSED_ISSUE_STATE = Gitlab::Graphql::Aggregations::Epics::Constants::CLOSED_ISSUE_STATE
WEIGHT_SUM_FACET = Epics::AggregateConstants::WEIGHT_SUM
COUNT_FACET = Epics::AggregateConstants::COUNT
WEIGHT_SUM_FACET = Gitlab::Graphql::Aggregations::Epics::Constants::WEIGHT_SUM
COUNT_FACET = Gitlab::Graphql::Aggregations::Epics::Constants::COUNT
end
# frozen_string_literal: true
module Gitlab
module Graphql
module Aggregations
module Epics
class BulkEpicAggregateLoader
include Constants
# This class retrieves each epic and its child epics recursively
# It allows us to recreate the epic tree structure in POROs
def initialize(epic_ids:)
@results = {}
@target_epic_ids = epic_ids.present? ? [epic_ids].flatten.compact : []
end
# rubocop: disable CodeReuse/ActiveRecord
def execute
return {} unless target_epic_ids.any?
# We do a left outer join in order to capture epics with no issues
# This is so we can aggregate the epic counts for every epic
raw_results = ::Gitlab::ObjectHierarchy.new(Epic.where(id: target_epic_ids)).base_and_descendants
.left_joins(epic_issues: :issue)
.group("issues.state_id", "epics.id", "epics.iid", "epics.parent_id", "epics.state_id")
.select("epics.id, epics.iid, epics.parent_id, epics.state_id AS epic_state_id, issues.state_id AS issues_state_id, COUNT(issues) AS issues_count, SUM(COALESCE(issues.weight, 0)) AS issues_weight_sum")
raw_results = raw_results.map(&:attributes).map(&:with_indifferent_access)
group_by_epic_id(raw_results)
@results
end
# rubocop: enable CodeReuse/ActiveRecord
private
attr_reader :target_epic_ids, :results
def group_by_epic_id(raw_records)
# for each id, populate with matching records
# change from a series of { id: x ... } to { x => [{...}, {...}] }
raw_records.map { |r| r[:id] }.uniq.each do |epic_id|
records = []
matching_records = raw_records.select { |record| record[:id] == epic_id }
matching_records.each do |record|
records << record.except(:id).to_h.with_indifferent_access
end
@results[epic_id] = records
end
end
end
end
end
end
end
......@@ -2,7 +2,7 @@
require 'spec_helper'
describe Epics::BulkEpicAggregateLoader do
describe Gitlab::Graphql::Aggregations::Epics::BulkEpicAggregateLoader do
let_it_be(:closed_issue_state_id) { Issue.available_states[:closed] }
let_it_be(:opened_issue_state_id) { Issue.available_states[:opened] }
......
......@@ -2,7 +2,7 @@
require 'spec_helper'
describe Epics::EpicNode do
describe Gitlab::Graphql::Aggregations::Epics::EpicNode do
include_context 'includes EpicAggregate constants'
let(:epic_id) { 34 }
......@@ -148,7 +148,7 @@ describe Epics::EpicNode do
context 'with an issue with 0 weight' do
let(:direct_sums) do
[Epics::EpicNode::Sum.new(COUNT_FACET, CLOSED_EPIC_STATE, ISSUE_TYPE, 1)]
[Gitlab::Graphql::Aggregations::Epics::EpicNode::Sum.new(COUNT_FACET, CLOSED_EPIC_STATE, ISSUE_TYPE, 1)]
end
it 'returns a SumTotal with only a weight sum' do
......@@ -162,8 +162,8 @@ describe Epics::EpicNode do
context 'with an issue with nonzero weight' do
let(:direct_sums) do
[
Epics::EpicNode::Sum.new(COUNT_FACET, CLOSED_EPIC_STATE, ISSUE_TYPE, 1),
Epics::EpicNode::Sum.new(WEIGHT_SUM_FACET, CLOSED_EPIC_STATE, ISSUE_TYPE, 2)
Gitlab::Graphql::Aggregations::Epics::EpicNode::Sum.new(COUNT_FACET, CLOSED_EPIC_STATE, ISSUE_TYPE, 1),
Gitlab::Graphql::Aggregations::Epics::EpicNode::Sum.new(WEIGHT_SUM_FACET, CLOSED_EPIC_STATE, ISSUE_TYPE, 2)
]
end
......@@ -184,7 +184,7 @@ describe Epics::EpicNode do
let(:child_epic_node) { described_class.new(child_epic_id, [{ parent_id: epic_id, epic_state_id: CLOSED_EPIC_STATE }]) }
let(:direct_sums) do
[ # only one opened epic, the child
Epics::EpicNode::Sum.new(COUNT_FACET, OPENED_EPIC_STATE, EPIC_TYPE, 1)
Gitlab::Graphql::Aggregations::Epics::EpicNode::Sum.new(COUNT_FACET, OPENED_EPIC_STATE, EPIC_TYPE, 1)
]
end
......@@ -196,8 +196,8 @@ describe Epics::EpicNode do
context 'with a child that has issues of nonzero weight' do
let(:child_sums) do
[ # 1 issue of weight 2
Epics::EpicNode::Sum.new(COUNT_FACET, OPENED_ISSUE_STATE, ISSUE_TYPE, 1),
Epics::EpicNode::Sum.new(WEIGHT_SUM_FACET, OPENED_ISSUE_STATE, ISSUE_TYPE, 2)
Gitlab::Graphql::Aggregations::Epics::EpicNode::Sum.new(COUNT_FACET, OPENED_ISSUE_STATE, ISSUE_TYPE, 1),
Gitlab::Graphql::Aggregations::Epics::EpicNode::Sum.new(WEIGHT_SUM_FACET, OPENED_ISSUE_STATE, ISSUE_TYPE, 2)
]
end
......
......@@ -2,7 +2,7 @@
require 'spec_helper'
describe Epics::LazyEpicAggregate do
describe Gitlab::Graphql::Aggregations::Epics::LazyEpicAggregate do
include_context 'includes EpicAggregate constants'
let(:query_ctx) do
......@@ -44,7 +44,7 @@ describe Epics::LazyEpicAggregate do
let(:single_record) do
{ iid: 6, issues_count: 4, issues_weight_sum: 9, parent_id: nil, issues_state_id: OPENED_ISSUE_STATE, epic_state_id: OPENED_EPIC_STATE }
end
let(:epic_info_node) { Epics::EpicNode.new(epic_id, [single_record] ) }
let(:epic_info_node) { Gitlab::Graphql::Aggregations::Epics::EpicNode.new(epic_id, [single_record] ) }
subject { described_class.new(query_ctx, epic_id, :count) }
......@@ -59,7 +59,7 @@ describe Epics::LazyEpicAggregate do
it 'does not make the query again' do
expect(epic_info_node).to receive(:aggregate_object_by).with(subject.facet)
expect(Epics::BulkEpicAggregateLoader).not_to receive(:new)
expect(Gitlab::Graphql::Aggregations::Epics::BulkEpicAggregateLoader).not_to receive(:new)
subject.epic_aggregate
end
......@@ -79,8 +79,8 @@ describe Epics::LazyEpicAggregate do
end
before do
allow(Epics::EpicNode).to receive(:aggregate_object_by).and_call_original
expect_any_instance_of(Epics::BulkEpicAggregateLoader).to receive(:execute).and_return(fake_data)
allow(Gitlab::Graphql::Aggregations::Epics::EpicNode).to receive(:aggregate_object_by).and_call_original
expect_any_instance_of(Gitlab::Graphql::Aggregations::Epics::BulkEpicAggregateLoader).to receive(:execute).and_return(fake_data)
end
it 'clears the pending IDs' do
......
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