Commit a883852c authored by charlieablett's avatar charlieablett

Simplify auxiliary classes

- Remove utility classes
- Store more state in EpicNode
parent 920c4e70
......@@ -2,6 +2,8 @@
module Types
class EpicType < BaseObject
include ::Gitlab::Graphql::Aggregations::Epics::Constants
graphql_name 'Epic'
description 'Represents an epic.'
......@@ -124,17 +126,17 @@ module Types
description: 'Number of open and closed descendant epics and issues',
resolve: -> (epic, args, ctx) do
if Feature.enabled?(:unfiltered_epic_aggregates)
Epics::LazyEpicAggregate.new(ctx, epic.id, Epics::LazyEpicAggregate::COUNT)
Gitlab::Graphql::Aggregations::Epics::LazyEpicAggregate.new(ctx, epic.id, COUNT)
else
Epics::DescendantCountService.new(epic, ctx[:current_user])
end
end
field :descendant_weight_sum, Types::EpicDescendantWeightSumType, null: true, complexity: 10,
description: "Total weight of open and closed descendant epic's issues",
description: "Total weight of open and closed issues in the epic and its descendants",
feature_flag: :unfiltered_epic_aggregates,
resolve: -> (epic, args, ctx) do
Epics::LazyEpicAggregate.new(ctx, epic.id, Epics::LazyEpicAggregate::WEIGHT_SUM)
Gitlab::Graphql::Aggregations::Epics::LazyEpicAggregate.new(ctx, epic.id, WEIGHT_SUM)
end
field :health_status,
......
# 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
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
# This class represents an Epic's aggregate information (added up counts) about its child epics and immediate issues
module Gitlab
module Graphql
module Aggregations
module Epics
class EpicNode
include Constants
include ::Gitlab::Graphql::Aggregations::Epics::Constants
include Gitlab::Utils::StrongMemoize
attr_reader :epic_id, :epic_state_id, :epic_info_flat_list, :parent_id,
:direct_sums, # we calculate these and recursively add them
:sum_total
:immediate_count_totals, :immediate_weight_sum_totals, # only counts/weights of immediate issues and child epic counts
:count_aggregate, :weight_sum_aggregate
attr_accessor :child_ids
attr_accessor :child_ids, :calculated_count_totals, :calculated_weight_sum_totals
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,
# They include the sum of each epic's immediate 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 = []
@immediate_count_totals = []
@immediate_weight_sum_totals = []
set_epic_attributes(flat_info_list.first) # there will always be one
end
def assemble_issue_sums
def assemble_issue_totals
# this is a representation of the epic's
# direct child issues and epics that have come from the DB
# immediate 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
......@@ -41,43 +43,89 @@ module Gitlab
end
end
def assemble_epic_sums(children)
def assemble_epic_totals(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
def aggregate_count(tree)
strong_memoize(:count_aggregate) do
calculate_recursive_sums(COUNT, tree)
OpenStruct.new({
opened_issues: sum_objects(COUNT, OPENED_ISSUE_STATE, ISSUE_TYPE),
closed_issues: sum_objects(COUNT, CLOSED_ISSUE_STATE, ISSUE_TYPE),
opened_epics: sum_objects(COUNT, OPENED_EPIC_STATE, EPIC_TYPE),
closed_epics: sum_objects(COUNT, CLOSED_EPIC_STATE, EPIC_TYPE)
})
end
end
@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)
def aggregate_weight_sum(tree)
strong_memoize(:weight_sum_aggregate) do
calculate_recursive_sums(WEIGHT_SUM, tree)
OpenStruct.new({
opened_issues: sum_objects(WEIGHT_SUM, OPENED_ISSUE_STATE, ISSUE_TYPE),
closed_issues: sum_objects(WEIGHT_SUM, CLOSED_ISSUE_STATE, ISSUE_TYPE)
})
end
end
def immediate_totals(facet)
strong_memoize(:"immediate_#{facet}_totals") do
[]
end
sum_total.add(direct_sums)
sum_total
end
def aggregate_object_by(facet)
sum_total.by_facet(facet)
def calculated_totals(facet)
if facet == COUNT
return calculated_count_totals
end
calculated_weight_sum_totals
end
def to_s
{ epic_id: @epic_id, parent_id: @parent_id, direct_sums: direct_sums, child_ids: child_ids }.to_json
{
epic_id: @epic_id,
parent_id: @parent_id,
immediate_count_totals: immediate_count_totals,
immediate_weight_sum_totals: immediate_weight_sum_totals,
child_ids: child_ids
}.to_json
end
alias_method :inspect, :to_s
alias_method :id, :epic_id
def calculate_recursive_sums(facet, tree)
return calculated_totals(facet) if calculated_totals(facet)
sum_total = []
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(facet, tree)
sum_total.concat(child_sums)
end
sum_total.concat(immediate_totals(facet))
set_calculated_total(facet, sum_total)
end
private
def sum_objects(facet, state, type)
sums = calculated_totals(facet) || []
matching = sums.select { |sum| sum.state == state && sum.type == type }
return 0 if sums.empty?
matching.map(&:value).reduce(:+) || 0
end
def create_sum_if_needed(facet, state, type, value)
return if value.nil? || value < 1
direct_sums << Sum.new(facet, state, type, value)
immediate_totals(facet) << Sum.new(facet, state, type, value)
end
def set_epic_attributes(record)
......@@ -85,6 +133,14 @@ module Gitlab
@parent_id = record[:parent_id]
end
def set_calculated_total(facet, calculated_sums)
if facet == COUNT
@calculated_count_totals = calculated_sums
else
@calculated_weight_sum_totals = calculated_sums
end
end
Sum = Struct.new(:facet, :state, :type, :value) do
def inspect
"<Sum facet=#{facet}, state=#{state}, type=#{type}, value=#{value}>"
......
......@@ -5,7 +5,7 @@ module Gitlab
module Aggregations
module Epics
class LazyEpicAggregate
include Constants
include ::Gitlab::Graphql::Aggregations::Epics::Constants
attr_reader :facet, :epic_id, :lazy_state
......@@ -38,7 +38,7 @@ module Gitlab
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)
aggregate_object(loaded_epic_info_node)
else
load_records_into_tree
end
......@@ -56,16 +56,16 @@ module Gitlab
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
raw_epic_aggregates = Gitlab::Graphql::Loaders::BulkEpicAggregateLoader.new(epic_ids: pending_ids).execute
# Assemble the tree and sum everything
# Assemble the tree and sum immediate child epic/issues
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)
aggregate_object(epic_node)
end
def create_structure_from(aggregate_records)
......@@ -78,8 +78,7 @@ module Gitlab
end
associate_parents_and_children
assemble_direct_sums
calculate_recursive_sums
assemble_immediate_totals
end
# each of the methods below are done one after the other
......@@ -89,18 +88,20 @@ module Gitlab
end
end
def assemble_direct_sums
def assemble_immediate_totals
tree.each do |_, node|
node.assemble_issue_sums
node.assemble_issue_totals
node_children = tree.select { |_, child_node| node.epic_id == child_node.parent_id }.values
node.assemble_epic_sums(node_children)
node.assemble_epic_totals(node_children)
end
end
def calculate_recursive_sums
tree.each do |_, node|
node.calculate_recursive_sums(tree)
def aggregate_object(node)
if @facet == COUNT
node.aggregate_count(tree)
else
node.aggregate_weight_sum(tree)
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
# frozen_string_literal: true
module Gitlab
module Graphql
module Loaders
class BulkEpicAggregateLoader
include ::Gitlab::Graphql::Aggregations::Epics::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
# frozen_string_literal: true
require 'spec_helper'
describe Epics::Aggregate do
include_context 'includes EpicAggregate constants'
context 'when CountAggregate' do
subject { Epics::CountAggregate.new(sums) }
describe 'summation' do
let(:sums) { [] }
context 'when there are no sum objects' do
it 'returns 0 for all values', :aggregate_failures do
expect(subject.opened_issues).to eq 0
expect(subject.closed_issues).to eq 0
expect(subject.opened_epics).to eq 0
expect(subject.closed_epics).to eq 0
end
end
context 'when some sums exist' do
let(:sums) do
[
double(:sum, facet: COUNT_FACET, type: EPIC_TYPE, value: 1, state: OPENED_EPIC_STATE),
double(:sum, facet: COUNT_FACET, type: EPIC_TYPE, value: 1, state: CLOSED_EPIC_STATE),
double(:sum, facet: COUNT_FACET, type: ISSUE_TYPE, value: 1, state: OPENED_ISSUE_STATE),
double(:sum, facet: COUNT_FACET, type: ISSUE_TYPE, value: 1, state: OPENED_ISSUE_STATE),
double(:sum, facet: WEIGHT_SUM_FACET, type: ISSUE_TYPE, value: 22, state: CLOSED_ISSUE_STATE)
]
end
it 'returns sums of appropriate values', :aggregate_failures do
expect(subject.opened_issues).to eq 2
expect(subject.closed_issues).to eq 0
expect(subject.opened_epics).to eq 1
expect(subject.closed_epics).to eq 1
end
end
end
end
context 'when WeightSumAggregate' do
subject { Epics::WeightSumAggregate.new(sums) }
describe 'summation' do
let(:sums) { [] }
context 'when there are no sum objects' do
it 'returns 0 for all values', :aggregate_failures do
expect(subject.opened_issues).to eq 0
expect(subject.closed_issues).to eq 0
end
end
context 'when some sums exist' do
let(:sums) do
[
double(:sum, facet: WEIGHT_SUM_FACET, type: ISSUE_TYPE, value: 1, state: OPENED_ISSUE_STATE),
double(:sum, facet: WEIGHT_SUM_FACET, type: ISSUE_TYPE, value: 1, state: OPENED_ISSUE_STATE),
double(:sum, facet: COUNT_FACET, type: ISSUE_TYPE, value: 22, state: CLOSED_ISSUE_STATE)
]
end
it 'returns sums of appropriate values', :aggregate_failures do
expect(subject.opened_issues).to eq 2
expect(subject.closed_issues).to eq 0
end
end
end
end
end
......@@ -31,7 +31,7 @@ describe Gitlab::Graphql::Aggregations::Epics::EpicNode do
it_behaves_like 'setting attributes based on the first record', { epic_state_id: CLOSED_EPIC_STATE, parent_id: 2 }
end
describe '#assemble_issue_sums' do
describe '#assemble_issue_totals' do
subject { described_class.new(epic_id, fake_data) }
context 'an epic with no issues' do
......@@ -41,10 +41,11 @@ describe Gitlab::Graphql::Aggregations::Epics::EpicNode do
]
end
it 'does not create any sums' do
subject.assemble_issue_sums
it 'does not create any totals' do
subject.assemble_issue_totals
expect(subject.direct_sums.count).to eq 0
expect(subject.immediate_totals(COUNT_FACET).count).to eq 0
expect(subject.immediate_totals(WEIGHT_SUM_FACET).count).to eq 0
end
end
......@@ -57,10 +58,11 @@ describe Gitlab::Graphql::Aggregations::Epics::EpicNode do
end
it 'creates no sums for the weight if the issues have 0 weight' do
subject.assemble_issue_sums
subject.assemble_issue_totals
expect(subject.direct_sums.count).to eq 1
expect(subject).to have_direct_sum(ISSUE_TYPE, COUNT_FACET, OPENED_ISSUE_STATE, 1)
expect(subject.immediate_totals(COUNT_FACET).count).to eq 1
expect(subject.immediate_totals(WEIGHT_SUM_FACET).count).to eq 0
expect(subject).to have_immediate_total(ISSUE_TYPE, COUNT_FACET, OPENED_ISSUE_STATE, 1)
end
end
......@@ -72,11 +74,10 @@ describe Gitlab::Graphql::Aggregations::Epics::EpicNode do
end
it 'creates two sums' do
subject.assemble_issue_sums
subject.assemble_issue_totals
expect(subject.direct_sums.count).to eq 2
expect(subject).to have_direct_sum(ISSUE_TYPE, COUNT_FACET, OPENED_ISSUE_STATE, 1)
expect(subject).to have_direct_sum(ISSUE_TYPE, WEIGHT_SUM_FACET, OPENED_ISSUE_STATE, 2)
expect(subject).to have_immediate_total(ISSUE_TYPE, COUNT_FACET, OPENED_ISSUE_STATE, 1)
expect(subject).to have_immediate_total(ISSUE_TYPE, WEIGHT_SUM_FACET, OPENED_ISSUE_STATE, 2)
end
end
......@@ -89,19 +90,18 @@ describe Gitlab::Graphql::Aggregations::Epics::EpicNode do
end
it 'creates two sums' do
subject.assemble_issue_sums
subject.assemble_issue_totals
expect(subject.direct_sums.count).to eq 4
expect(subject).to have_direct_sum(ISSUE_TYPE, COUNT_FACET, OPENED_ISSUE_STATE, 1)
expect(subject).to have_direct_sum(ISSUE_TYPE, WEIGHT_SUM_FACET, OPENED_ISSUE_STATE, 2)
expect(subject).to have_direct_sum(ISSUE_TYPE, COUNT_FACET, CLOSED_ISSUE_STATE, 3)
expect(subject).to have_direct_sum(ISSUE_TYPE, WEIGHT_SUM_FACET, CLOSED_ISSUE_STATE, 5)
expect(subject).to have_immediate_total(ISSUE_TYPE, COUNT_FACET, OPENED_ISSUE_STATE, 1)
expect(subject).to have_immediate_total(ISSUE_TYPE, WEIGHT_SUM_FACET, OPENED_ISSUE_STATE, 2)
expect(subject).to have_immediate_total(ISSUE_TYPE, COUNT_FACET, CLOSED_ISSUE_STATE, 3)
expect(subject).to have_immediate_total(ISSUE_TYPE, WEIGHT_SUM_FACET, CLOSED_ISSUE_STATE, 5)
end
end
end
end
describe '#assemble_epic_sums' do
describe '#assemble_epic_totals' do
subject { described_class.new(epic_id, [{ parent_id: nil, epic_state_id: CLOSED_EPIC_STATE }]) }
context 'with a child epic' do
......@@ -113,10 +113,9 @@ describe Gitlab::Graphql::Aggregations::Epics::EpicNode do
end
it 'adds up the number of the child epics' do
subject.assemble_epic_sums([child_epic_node])
subject.assemble_epic_totals([child_epic_node])
expect(subject.direct_sums.count).to eq 1
expect(subject).to have_direct_sum(EPIC_TYPE, COUNT_FACET, CLOSED_EPIC_STATE, 1)
expect(subject).to have_immediate_total(EPIC_TYPE, COUNT_FACET, CLOSED_EPIC_STATE, 1)
end
end
end
......@@ -125,7 +124,16 @@ describe Gitlab::Graphql::Aggregations::Epics::EpicNode do
subject { described_class.new(epic_id, [{ parent_id: nil, epic_state_id: CLOSED_EPIC_STATE }]) }
before do
allow(subject).to receive(:direct_sums).and_return(direct_sums)
allow(subject).to receive(:immediate_totals).with(COUNT_FACET).and_return(immediate_count_totals)
allow(subject).to receive(:immediate_totals).with(WEIGHT_SUM_FACET).and_return(immediate_weight_sum_totals)
end
shared_examples 'returns calculated totals by facet' do |facet, count|
it 'returns a calculated_count_total' do
subject.calculate_recursive_sums(facet, tree)
expect(subject.calculated_totals(facet).count).to eq count
end
end
context 'an epic with no child epics' do
......@@ -134,45 +142,37 @@ describe Gitlab::Graphql::Aggregations::Epics::EpicNode do
end
context 'with no child issues' do
let(:direct_sums) do
[]
end
it 'returns a SumTotal with no sums' do
result = subject.calculate_recursive_sums(tree)
let(:immediate_count_totals) { [] }
let(:immediate_weight_sum_totals) { [] }
expect(result.sums).not_to be_nil
expect(result.sums.count).to eq 0
end
it_behaves_like 'returns calculated totals by facet', COUNT_FACET, 0
it_behaves_like 'returns calculated totals by facet', WEIGHT_SUM_FACET, 0
end
context 'with an issue with 0 weight' do
let(:direct_sums) do
let(:immediate_count_totals) do
[Gitlab::Graphql::Aggregations::Epics::EpicNode::Sum.new(COUNT_FACET, CLOSED_EPIC_STATE, ISSUE_TYPE, 1)]
end
let(:immediate_weight_sum_totals) { [] }
it 'returns a SumTotal with only a weight sum' do
result = subject.calculate_recursive_sums(tree)
expect(result.sums).not_to be_nil
expect(result.sums.count).to eq 1
end
it_behaves_like 'returns calculated totals by facet', COUNT_FACET, 1
it_behaves_like 'returns calculated totals by facet', WEIGHT_SUM_FACET, 0
end
context 'with an issue with nonzero weight' do
let(:direct_sums) do
let(:immediate_count_totals) do
[
Gitlab::Graphql::Aggregations::Epics::EpicNode::Sum.new(COUNT_FACET, CLOSED_EPIC_STATE, ISSUE_TYPE, 1)
]
end
let(:immediate_weight_sum_totals) do
[
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
it 'returns a SumTotal with only a weight sum' do
result = subject.calculate_recursive_sums(tree)
expect(result.sums).not_to be_nil
expect(result.sums.count).to eq 2
end
it_behaves_like 'returns calculated totals by facet', COUNT_FACET, 1
it_behaves_like 'returns calculated totals by facet', WEIGHT_SUM_FACET, 1
end
end
......@@ -182,30 +182,41 @@ describe Gitlab::Graphql::Aggregations::Epics::EpicNode do
{ epic_id => subject, child_epic_id => child_epic_node }
end
let(:child_epic_node) { described_class.new(child_epic_id, [{ parent_id: epic_id, epic_state_id: CLOSED_EPIC_STATE }]) }
let(:direct_sums) do
let(:immediate_count_totals) do
[ # only one opened epic, the child
Gitlab::Graphql::Aggregations::Epics::EpicNode::Sum.new(COUNT_FACET, OPENED_EPIC_STATE, EPIC_TYPE, 1)
]
end
let(:immediate_weight_sum_totals) { [] }
before do
subject.child_ids << child_epic_id
allow(child_epic_node).to receive(:direct_sums).and_return(child_sums)
allow(child_epic_node).to receive(:immediate_totals).with(COUNT_FACET).and_return(child_count_totals)
allow(child_epic_node).to receive(:immediate_totals).with(WEIGHT_SUM_FACET).and_return(child_weight_sum_totals)
end
context 'with a child that has issues of nonzero weight' do
let(:child_sums) do
[ # 1 issue of weight 2
Gitlab::Graphql::Aggregations::Epics::EpicNode::Sum.new(COUNT_FACET, OPENED_ISSUE_STATE, ISSUE_TYPE, 1),
let(:child_count_totals) do
[
Gitlab::Graphql::Aggregations::Epics::EpicNode::Sum.new(COUNT_FACET, OPENED_ISSUE_STATE, ISSUE_TYPE, 1)
]
end
let(:child_weight_sum_totals) do
[
Gitlab::Graphql::Aggregations::Epics::EpicNode::Sum.new(WEIGHT_SUM_FACET, OPENED_ISSUE_STATE, ISSUE_TYPE, 2)
]
end
it 'returns the correct sum total' do
result = subject.calculate_recursive_sums(tree)
it 'returns the correct count total' do
subject.calculate_recursive_sums(COUNT_FACET, tree)
expect(subject.calculated_count_totals.count).to eq 2
end
it 'returns the correct weight sum total' do
subject.calculate_recursive_sums(WEIGHT_SUM_FACET, tree)
expect(result.sums).not_to be_nil
expect(result.sums.count).to eq 3
expect(subject.calculated_weight_sum_totals.count).to eq 1
end
end
end
......
......@@ -58,8 +58,8 @@ describe Gitlab::Graphql::Aggregations::Epics::LazyEpicAggregate do
end
it 'does not make the query again' do
expect(epic_info_node).to receive(:aggregate_object_by).with(subject.facet)
expect(Gitlab::Graphql::Aggregations::Epics::BulkEpicAggregateLoader).not_to receive(:new)
expect(epic_info_node).to receive(:aggregate_count)
expect(Gitlab::Graphql::Loaders::BulkEpicAggregateLoader).not_to receive(:new)
subject.epic_aggregate
end
......@@ -79,8 +79,8 @@ describe Gitlab::Graphql::Aggregations::Epics::LazyEpicAggregate do
end
before do
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)
allow(Gitlab::Graphql::Aggregations::Epics::EpicNode).to receive(:aggregate_count).and_call_original
expect_any_instance_of(Gitlab::Graphql::Loaders::BulkEpicAggregateLoader).to receive(:execute).and_return(fake_data)
end
it 'clears the pending IDs' do
......@@ -108,12 +108,12 @@ describe Gitlab::Graphql::Aggregations::Epics::LazyEpicAggregate do
lazy_state = subject.instance_variable_get(:@lazy_state)
tree = lazy_state[:tree]
expect(tree[epic_id]).to have_direct_sum(EPIC_TYPE, COUNT_FACET, CLOSED_EPIC_STATE, 1)
expect(tree[epic_id]).to have_direct_sum(ISSUE_TYPE, WEIGHT_SUM_FACET, OPENED_ISSUE_STATE, 5)
expect(tree[epic_id]).to have_direct_sum(EPIC_TYPE, COUNT_FACET, CLOSED_EPIC_STATE, 1)
expect(tree[epic_id]).to have_immediate_total(EPIC_TYPE, COUNT_FACET, CLOSED_EPIC_STATE, 1)
expect(tree[epic_id]).to have_immediate_total(ISSUE_TYPE, WEIGHT_SUM_FACET, OPENED_ISSUE_STATE, 5)
expect(tree[epic_id]).to have_immediate_total(EPIC_TYPE, COUNT_FACET, CLOSED_EPIC_STATE, 1)
expect(tree[child_epic_id]).to have_direct_sum(ISSUE_TYPE, COUNT_FACET, CLOSED_ISSUE_STATE, 4)
expect(tree[child_epic_id]).to have_direct_sum(ISSUE_TYPE, WEIGHT_SUM_FACET, CLOSED_ISSUE_STATE, 17)
expect(tree[child_epic_id]).to have_immediate_total(ISSUE_TYPE, COUNT_FACET, CLOSED_ISSUE_STATE, 4)
expect(tree[child_epic_id]).to have_immediate_total(ISSUE_TYPE, WEIGHT_SUM_FACET, CLOSED_ISSUE_STATE, 17)
end
it 'assembles recursive sums for the parent', :aggregate_failures do
......@@ -122,22 +122,23 @@ describe Gitlab::Graphql::Aggregations::Epics::LazyEpicAggregate do
lazy_state = subject.instance_variable_get(:@lazy_state)
tree = lazy_state[:tree]
expect(tree[epic_id]).to have_aggregate(ISSUE_TYPE, COUNT_FACET, OPENED_ISSUE_STATE, 2)
expect(tree[epic_id]).to have_aggregate(ISSUE_TYPE, COUNT_FACET, CLOSED_ISSUE_STATE, 4)
expect(tree[epic_id]).to have_aggregate(ISSUE_TYPE, WEIGHT_SUM_FACET, OPENED_ISSUE_STATE, 5)
expect(tree[epic_id]).to have_aggregate(ISSUE_TYPE, WEIGHT_SUM_FACET, CLOSED_ISSUE_STATE, 17)
expect(tree[epic_id]).to have_aggregate(EPIC_TYPE, COUNT_FACET, CLOSED_EPIC_STATE, 1)
expect(tree[epic_id]).to have_aggregate(tree, ISSUE_TYPE, COUNT_FACET, OPENED_ISSUE_STATE, 2)
expect(tree[epic_id]).to have_aggregate(tree, ISSUE_TYPE, COUNT_FACET, CLOSED_ISSUE_STATE, 4)
expect(tree[epic_id]).to have_aggregate(tree, ISSUE_TYPE, WEIGHT_SUM_FACET, OPENED_ISSUE_STATE, 5)
expect(tree[epic_id]).to have_aggregate(tree, ISSUE_TYPE, WEIGHT_SUM_FACET, CLOSED_ISSUE_STATE, 17)
expect(tree[epic_id]).to have_aggregate(tree, EPIC_TYPE, COUNT_FACET, CLOSED_EPIC_STATE, 1)
end
end
context 'for a standalone epic with no issues' do
it 'assembles direct sums', :aggregate_failures do
it 'assembles direct totals', :aggregate_failures do
subject.epic_aggregate
lazy_state = subject.instance_variable_get(:@lazy_state)
tree = lazy_state[:tree]
expect(tree[other_epic_id].direct_sums).to be_empty
expect(tree[other_epic_id].immediate_count_totals).to be_empty
expect(tree[other_epic_id].immediate_weight_sum_totals).to be_empty
end
end
end
......
......@@ -2,7 +2,7 @@
require 'spec_helper'
describe Gitlab::Graphql::Aggregations::Epics::BulkEpicAggregateLoader do
describe Gitlab::Graphql::Loaders::BulkEpicAggregateLoader do
let_it_be(:closed_issue_state_id) { Issue.available_states[:closed] }
let_it_be(:opened_issue_state_id) { Issue.available_states[:opened] }
......
......@@ -57,102 +57,103 @@ describe 'Epic aggregates (count and weight)' do
post_graphql(query, current_user: current_user)
end
shared_examples 'counts properly' do
it_behaves_like 'a working graphql query'
shared_examples 'counts properly' do
it_behaves_like 'a working graphql query'
it 'returns the epic counts' do
epic_count_result = {
"openedEpics" => 1,
"closedEpics" => 2
}
it 'returns the epic counts' do
epic_count_result = {
"openedEpics" => 1,
"closedEpics" => 2
}
is_expected.to include(
a_hash_including('descendantCounts' => a_hash_including(epic_count_result))
)
end
is_expected.to include(
a_hash_including('descendantCounts' => a_hash_including(epic_count_result))
)
end
it 'returns the issue counts' do
issue_count_result = {
"openedIssues" => 1,
"closedIssues" => 1
}
it 'returns the issue counts' do
issue_count_result = {
"openedIssues" => 1,
"closedIssues" => 1
}
is_expected.to include(
a_hash_including('descendantCounts' => a_hash_including(issue_count_result))
)
is_expected.to include(
a_hash_including('descendantCounts' => a_hash_including(issue_count_result))
)
end
end
end
context 'with feature flag enabled' do
before do
stub_feature_flags(unfiltered_epic_aggregates: true)
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
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(Gitlab::Graphql::Aggregations::Epics::LazyEpicAggregate).to receive(:new).twice
post_graphql(query, current_user: current_user)
end
post_graphql(query, current_user: current_user)
end
it_behaves_like 'counts properly'
it_behaves_like 'counts properly'
it 'returns the weights' do
descendant_weight_result = {
"openedIssues" => 5,
"closedIssues" => 7
}
it 'returns the weights' do
descendant_weight_result = {
"openedIssues" => 5,
"closedIssues" => 7
}
is_expected.to include(
a_hash_including('descendantWeightSum' => a_hash_including(descendant_weight_result))
)
is_expected.to include(
a_hash_including('descendantWeightSum' => a_hash_including(descendant_weight_result))
)
end
end
end
context 'with feature flag disabled' do
before do
stub_feature_flags(unfiltered_epic_aggregates: false)
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
context 'when requesting counts' do
let(:epic_aggregates_query) do
<<~QUERY
nodes {
descendantCounts {
openedEpics
closedEpics
openedIssues
closedIssues
}
}
}
QUERY
end
QUERY
end
it 'uses the DescendantCountService' do
expect(Epics::DescendantCountService).to receive(:new)
it 'uses the DescendantCountService' do
expect(Epics::DescendantCountService).to receive(:new)
post_graphql(query, current_user: current_user)
end
post_graphql(query, current_user: current_user)
end
it_behaves_like 'counts properly'
end
it_behaves_like 'counts properly'
end
context 'when requesting weights' do
let(:epic_aggregates_query) do
<<~QUERY
nodes {
descendantWeightSum {
openedIssues
closedIssues
context 'when requesting weights' do
let(:epic_aggregates_query) do
<<~QUERY
nodes {
descendantWeightSum {
openedIssues
closedIssues
}
}
}
QUERY
end
QUERY
end
it 'returns an error' do
post_graphql(query, current_user: current_user)
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/
expect_graphql_errors_to_include /Field 'descendantWeightSum' doesn't exist on type 'Epic/
end
end
end
end
......
# frozen_string_literal: true
RSpec::Matchers.define :have_direct_sum do |type, facet, state, value|
RSpec::Matchers.define :have_immediate_total do |type, facet, state, expected_value|
match do |epic_node_result|
expect(epic_node_result).not_to be_nil
expect(epic_node_result.direct_sums).not_to be_empty
immediate_totals = epic_node_result.immediate_totals(facet)
expect(immediate_totals).not_to be_empty
matching = epic_node_result.direct_sums.select { |sum| sum.type == type && sum.facet == facet && sum.state == state && sum.value == value }
matching = immediate_totals.select { |sum| sum.type == type && sum.facet == facet && sum.state == state && sum.value == expected_value }
expect(matching).not_to be_empty
end
......@@ -13,24 +14,25 @@ RSpec::Matchers.define :have_direct_sum do |type, facet, state, value|
if epic_node_result.nil?
"expected for there to be an epic node, but it is nil"
else
immediate_totals = epic_node_result.immediate_totals(facet)
<<~FAILURE_MSG
expected epic node with id #{epic_node_result.epic_id} to have a sum with facet '#{facet}', state '#{state}', type '#{type}' and value '#{value}'. Has #{epic_node_result.direct_sums.count} sum objects#{", none of which match" if epic_node_result.direct_sums.count > 0}.
Sums: #{epic_node_result.direct_sums.inspect}
expected epic node with id #{epic_node_result.epic_id} to have a sum with facet '#{facet}', state '#{state}', type '#{type}' and value '#{expected_value}'. Has #{immediate_totals.count} immediate sum objects#{", none of which match" if immediate_totals.count > 0}.
Sums: #{immediate_totals.inspect}
FAILURE_MSG
end
end
end
RSpec::Matchers.define :have_aggregate do |type, facet, state, value|
RSpec::Matchers.define :have_aggregate do |tree, type, facet, state, expected_value|
match do |epic_node_result|
aggregate_object = epic_node_result.aggregate_object_by(facet)
expect(aggregate_object.send(method_name(type, state))).to eq value
aggregate_object = epic_node_result.public_send(:"aggregate_#{facet}", tree)
expect(aggregate_object.public_send(method_name(type, state))).to eq expected_value
end
failure_message do |epic_node_result|
aggregate_object = epic_node_result.aggregate_object_by(facet)
aggregate_object = epic_node_result.public_send(:"aggregate_#{facet}", tree)
aggregate_method = method_name(type, state)
"Epic node with id #{epic_node_result.epic_id} called #{aggregate_method} on aggregate object of type #{aggregate_object.class.name}. Value was expected to be #{value} but was #{aggregate_object.send(aggregate_method)}."
"Epic node with id #{epic_node_result.epic_id} called #{aggregate_method} on aggregate object. Value was expected to be #{expected_value} but was #{aggregate_object.send(aggregate_method)}."
end
def method_name(type, state)
......
# 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
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