Commit 4b5b3064 authored by Vitali Tatarintev's avatar Vitali Tatarintev

Merge branch '324075-vsa-pagination' into 'master'

Add pagination for VSA records

See merge request gitlab-org/gitlab!57843
parents 03eaf018 795d9956
...@@ -56,7 +56,11 @@ module Groups ...@@ -56,7 +56,11 @@ module Groups
def records def records
return render_403 unless can?(current_user, :read_group_stage, @group) return render_403 unless can?(current_user, :read_group_stage, @group)
render json: data_collector.serialized_records serialized_records = data_collector.serialized_records do |relation|
add_pagination_headers(relation)
end
render json: serialized_records
end end
def duration_chart def duration_chart
...@@ -135,6 +139,17 @@ module Groups ...@@ -135,6 +139,17 @@ module Groups
@value_stream = @group.value_streams.find(params[:value_stream_id]) @value_stream = @group.value_streams.find(params[:value_stream_id])
end end
end end
def add_pagination_headers(relation)
Gitlab::Pagination::OffsetHeaderBuilder.new(
request_context: self,
per_page: relation.limit_value,
page: relation.current_page,
next_page: relation.next_page,
prev_page: relation.prev_page,
params: permitted_cycle_analytics_params
).execute(exclude_total_headers: true, data_without_counts: true)
end
end end
end end
end end
......
...@@ -18,6 +18,7 @@ module Gitlab ...@@ -18,6 +18,7 @@ module Gitlab
:milestone_title, :milestone_title,
:sort, :sort,
:direction, :direction,
:page,
label_name: [].freeze, label_name: [].freeze,
assignee_username: [].freeze, assignee_username: [].freeze,
project_ids: [].freeze project_ids: [].freeze
...@@ -39,6 +40,7 @@ module Gitlab ...@@ -39,6 +40,7 @@ module Gitlab
attribute :value_stream attribute :value_stream
attribute :sort attribute :sort
attribute :direction attribute :direction
attribute :page
FINDER_PARAM_NAMES.each do |param_name| FINDER_PARAM_NAMES.each do |param_name|
attribute param_name attribute param_name
...@@ -68,7 +70,8 @@ module Gitlab ...@@ -68,7 +70,8 @@ module Gitlab
to: created_before, to: created_before,
project_ids: project_ids, project_ids: project_ids,
sort: sort&.to_sym, sort: sort&.to_sym,
direction: direction&.to_sym direction: direction&.to_sym,
page: page
}.merge(attributes.symbolize_keys.slice(*FINDER_PARAM_NAMES)) }.merge(attributes.symbolize_keys.slice(*FINDER_PARAM_NAMES))
end end
......
...@@ -234,6 +234,19 @@ RSpec.shared_examples 'Value Stream Analytics Stages controller' do ...@@ -234,6 +234,19 @@ RSpec.shared_examples 'Value Stream Analytics Stages controller' do
expect(response).to have_gitlab_http_status(:ok) expect(response).to have_gitlab_http_status(:ok)
end end
end end
context 'pagination' do
it 'exposes pagination headers' do
create_list(:merge_request, 3)
stub_const('Gitlab::Analytics::CycleAnalytics::RecordsFetcher::MAX_RECORDS', 2)
allow_any_instance_of(Gitlab::Analytics::CycleAnalytics::RecordsFetcher).to receive(:query).and_return(MergeRequest.join_metrics.all)
subject
expect(response.headers['X-Next-Page']).to eq('2')
expect(response.headers['Link']).to include('rel="next"')
end
end
end end
describe 'GET #duration_chart' do describe 'GET #duration_chart' do
......
...@@ -31,14 +31,34 @@ module Gitlab ...@@ -31,14 +31,34 @@ module Gitlab
@params = params @params = params
@sort = params[:sort] || :end_event @sort = params[:sort] || :end_event
@direction = params[:direction] || :desc @direction = params[:direction] || :desc
@page = params[:page] || 1
@per_page = MAX_RECORDS
end end
# rubocop: disable CodeReuse/ActiveRecord
def serialized_records def serialized_records
strong_memoize(:serialized_records) do strong_memoize(:serialized_records) do
# special case (legacy): 'Test' and 'Staging' stages should show Ci::Build records # special case (legacy): 'Test' and 'Staging' stages should show Ci::Build records
if default_test_stage? || default_staging_stage? if default_test_stage? || default_staging_stage?
ci_build_join = mr_metrics_table
.join(build_table)
.on(mr_metrics_table[:pipeline_id].eq(build_table[:commit_id]))
.join_sources
records = ordered_and_limited_query
.joins(ci_build_join)
.select(build_table[:id], *time_columns)
yield records if block_given?
ci_build_records = preload_ci_build_associations(records)
AnalyticsBuildSerializer.new.represent(ci_build_records.map { |e| e['build'] }) AnalyticsBuildSerializer.new.represent(ci_build_records.map { |e| e['build'] })
else else
records = ordered_and_limited_query.select(*columns, *time_columns)
yield records if block_given?
records = preload_associations(records)
records.map do |record| records.map do |record|
project = record.project project = record.project
attributes = record.attributes.merge({ attributes = record.attributes.merge({
...@@ -51,10 +71,11 @@ module Gitlab ...@@ -51,10 +71,11 @@ module Gitlab
end end
end end
end end
# rubocop: enable CodeReuse/ActiveRecord
private private
attr_reader :stage, :query, :params, :sort, :direction attr_reader :stage, :query, :params, :sort, :direction, :page, :per_page
def columns def columns
MAPPINGS.fetch(subject_class).fetch(:columns_for_select).map do |column_name| MAPPINGS.fetch(subject_class).fetch(:columns_for_select).map do |column_name|
...@@ -74,41 +95,32 @@ module Gitlab ...@@ -74,41 +95,32 @@ module Gitlab
MAPPINGS.fetch(subject_class).fetch(:serializer_class).new MAPPINGS.fetch(subject_class).fetch(:serializer_class).new
end end
# Loading Ci::Build records instead of MergeRequest records
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
def ci_build_records def preload_ci_build_associations(records)
ci_build_join = mr_metrics_table results = records.map(&:attributes)
.join(build_table)
.on(mr_metrics_table[:pipeline_id].eq(build_table[:commit_id]))
.join_sources
q = ordered_and_limited_query
.joins(ci_build_join)
.select(build_table[:id], *time_columns)
results = execute_query(q).to_a
Gitlab::CycleAnalytics::Updater.update!(results, from: 'id', to: 'build', klass: ::Ci::Build.includes({ project: [:namespace], user: [], pipeline: [] })) Gitlab::CycleAnalytics::Updater.update!(results, from: 'id', to: 'build', klass: ::Ci::Build.includes({ project: [:namespace], user: [], pipeline: [] }))
end end
# rubocop: enable CodeReuse/ActiveRecord
def ordered_and_limited_query def ordered_and_limited_query
order_by(query, sort, direction, columns).limit(MAX_RECORDS) strong_memoize(:ordered_and_limited_query) do
order_by(query, sort, direction, columns).page(page).per(per_page).without_count
end
end end
def records # rubocop: disable CodeReuse/ActiveRecord
results = ordered_and_limited_query def preload_associations(records)
.select(*columns, *time_columns)
# using preloader instead of includes to avoid AR generating a large column list # using preloader instead of includes to avoid AR generating a large column list
ActiveRecord::Associations::Preloader.new.preload( ActiveRecord::Associations::Preloader.new.preload(
results, records,
MAPPINGS.fetch(subject_class).fetch(:includes_for_query) MAPPINGS.fetch(subject_class).fetch(:includes_for_query)
) )
results records
end end
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: enable CodeReuse/ActiveRecord
def time_columns def time_columns
[ [
stage.start_event.timestamp_projection.as('start_event_timestamp'), stage.start_event.timestamp_projection.as('start_event_timestamp'),
......
...@@ -5,9 +5,9 @@ module Gitlab ...@@ -5,9 +5,9 @@ module Gitlab
class OffsetHeaderBuilder class OffsetHeaderBuilder
attr_reader :request_context, :per_page, :page, :next_page, :prev_page, :total, :total_pages attr_reader :request_context, :per_page, :page, :next_page, :prev_page, :total, :total_pages
delegate :params, :header, :request, to: :request_context delegate :request, to: :request_context
def initialize(request_context:, per_page:, page:, next_page:, prev_page: nil, total:, total_pages:) def initialize(request_context:, per_page:, page:, next_page:, prev_page: nil, total: nil, total_pages: nil, params: nil)
@request_context = request_context @request_context = request_context
@per_page = per_page @per_page = per_page
@page = page @page = page
...@@ -15,6 +15,7 @@ module Gitlab ...@@ -15,6 +15,7 @@ module Gitlab
@prev_page = prev_page @prev_page = prev_page
@total = total @total = total
@total_pages = total_pages @total_pages = total_pages
@params = params
end end
def execute(exclude_total_headers: false, data_without_counts: false) def execute(exclude_total_headers: false, data_without_counts: false)
...@@ -56,10 +57,24 @@ module Gitlab ...@@ -56,10 +57,24 @@ module Gitlab
end end
def page_href(next_page_params = {}) def page_href(next_page_params = {})
query_params = params.merge(**next_page_params, per_page: params[:per_page]).to_query query_params = params.merge(**next_page_params, per_page: per_page).to_query
build_page_url(query_params: query_params) build_page_url(query_params: query_params)
end end
def params
@params || request_context.params
end
def header(name, value)
if request_context.respond_to?(:header)
# For Grape API
request_context.header(name, value)
else
# For rails controllers
request_context.response.headers[name] = value
end
end
end end
end end
end end
...@@ -7,16 +7,15 @@ RSpec.describe Gitlab::Analytics::CycleAnalytics::RecordsFetcher do ...@@ -7,16 +7,15 @@ RSpec.describe Gitlab::Analytics::CycleAnalytics::RecordsFetcher do
Timecop.freeze { example.run } Timecop.freeze { example.run }
end end
let(:params) { { from: 1.year.ago, current_user: user } }
let_it_be(:project) { create(:project, :empty_repo) } let_it_be(:project) { create(:project, :empty_repo) }
let_it_be(:user) { create(:user) } let_it_be(:user) { create(:user) }
subject do subject do
Gitlab::Analytics::CycleAnalytics::DataCollector.new( Gitlab::Analytics::CycleAnalytics::DataCollector.new(
stage: stage, stage: stage,
params: { params: params
from: 1.year.ago,
current_user: user
}
).records_fetcher.serialized_records ).records_fetcher.serialized_records
end end
...@@ -131,4 +130,40 @@ RSpec.describe Gitlab::Analytics::CycleAnalytics::RecordsFetcher do ...@@ -131,4 +130,40 @@ RSpec.describe Gitlab::Analytics::CycleAnalytics::RecordsFetcher do
end end
end end
end end
describe 'pagination' do
let_it_be(:issue1) { create(:issue, project: project) }
let_it_be(:issue2) { create(:issue, project: project) }
let_it_be(:issue3) { create(:issue, project: project) }
let(:stage) do
build(:cycle_analytics_project_stage, {
start_event_identifier: :plan_stage_start,
end_event_identifier: :issue_first_mentioned_in_commit,
project: project
})
end
before(:all) do
issue1.metrics.update(first_added_to_board_at: 3.days.ago, first_mentioned_in_commit_at: 2.days.ago)
issue2.metrics.update(first_added_to_board_at: 3.days.ago, first_mentioned_in_commit_at: 2.days.ago)
issue3.metrics.update(first_added_to_board_at: 3.days.ago, first_mentioned_in_commit_at: 2.days.ago)
end
before do
project.add_user(user, Gitlab::Access::DEVELOPER)
stub_const('Gitlab::Analytics::CycleAnalytics::RecordsFetcher::MAX_RECORDS', 2)
end
it 'limits the results' do
expect(subject.size).to eq(2)
end
it 'loads the record for the next page' do
params[:page] = 2
expect(subject.size).to eq(1)
end
end
end end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::Pagination::OffsetHeaderBuilder, type: :controller do
controller(ActionController::Base) do
def index
relation = Project.where(archived: params[:archived]).page(params[:page]).per(1)
params_for_pagination = { archived: params[:archived], page: params[:page] }
Gitlab::Pagination::OffsetHeaderBuilder.new(
request_context: self,
per_page: relation.limit_value,
page: relation.current_page,
next_page: relation.next_page,
prev_page: relation.prev_page,
params: params_for_pagination
).execute(exclude_total_headers: true, data_without_counts: true)
render json: relation.map(&:id)
end
end
let_it_be(:projects) { create_list(:project, 2, archived: true) }
describe 'pagination' do
it 'returns correct result for the first page' do
get :index, params: { page: 1, archived: true }
expect(json_response).to eq([projects.first.id])
end
it 'returns correct result for the second page' do
get :index, params: { page: 2, archived: true }
expect(json_response).to eq([projects.last.id])
end
end
describe 'pagination heders' do
it 'adds next page header' do
get :index, params: { page: 1, archived: true }
expect(response.headers['X-Next-Page']).to eq('2')
end
it 'adds only the specified params to the lnk' do
get :index, params: { page: 1, archived: true, some_param: '1' }
expect(response.headers['Link']).not_to include('some_param')
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