Commit 8b393c9f authored by mbergeron's avatar mbergeron Committed by Micael Bergeron

Add batch loaded associations in API::Entities::MergeRequestBasic

With this change, whenever the Grape::Entity serializer is sent a list
of entities, all the associations that are batch loaded will be loaded
in a single SQL query.
parent 6566448f
...@@ -9,4 +9,9 @@ class LabelLink < ApplicationRecord ...@@ -9,4 +9,9 @@ class LabelLink < ApplicationRecord
validates :target, presence: true, unless: :importing? validates :target, presence: true, unless: :importing?
validates :label, presence: true, unless: :importing? validates :label, presence: true, unless: :importing?
scope :preloaded, -> { preload(:label) }
scope :for_targets, ->(type:, scope:) { where(target_type: type, target_id: scope) }
scope :for_merge_requests, ->(merge_request_scope) { for_targets(type: MergeRequest, scope: merge_request_scope) }
end end
...@@ -254,16 +254,11 @@ class MergeRequest < ApplicationRecord ...@@ -254,16 +254,11 @@ class MergeRequest < ApplicationRecord
scope :join_project, -> { joins(:target_project) } scope :join_project, -> { joins(:target_project) }
scope :references_project, -> { references(:target_project) } scope :references_project, -> { references(:target_project) }
PROJECT_ROUTE_AND_NAMESPACE_ROUTE = [
target_project: [:route, { namespace: :route }],
source_project: [:route, { namespace: :route }]
].freeze
scope :with_api_entity_associations, -> { scope :with_api_entity_associations, -> {
preload(:assignees, :author, :unresolved_notes, :labels, :milestone, preload(
:timelogs, :latest_merge_request_diff, target_project: [:route, { namespace: :route }],
*PROJECT_ROUTE_AND_NAMESPACE_ROUTE, source_project: [:route, { namespace: :route }]
metrics: [:latest_closed_by, :merged_by]) )
} }
scope :by_target_branch_wildcard, ->(wildcard_branch_name) do scope :by_target_branch_wildcard, ->(wildcard_branch_name) do
where("target_branch LIKE ?", ApplicationRecord.sanitize_sql_like(wildcard_branch_name).tr('*', '%')) where("target_branch LIKE ?", ApplicationRecord.sanitize_sql_like(wildcard_branch_name).tr('*', '%'))
......
...@@ -5,6 +5,11 @@ class MergeRequest::Metrics < ApplicationRecord ...@@ -5,6 +5,11 @@ class MergeRequest::Metrics < ApplicationRecord
belongs_to :pipeline, class_name: 'Ci::Pipeline', foreign_key: :pipeline_id belongs_to :pipeline, class_name: 'Ci::Pipeline', foreign_key: :pipeline_id
belongs_to :latest_closed_by, class_name: 'User' belongs_to :latest_closed_by, class_name: 'User'
belongs_to :merged_by, class_name: 'User' belongs_to :merged_by, class_name: 'User'
scope :preloaded, -> { preload(:merged_by, :latest_closed_by) }
scope :for_merge_requests, -> (merge_request_scope) do
where(merge_request: merge_request_scope)
end
end end
MergeRequest::Metrics.prepend_if_ee('EE::MergeRequest::Metrics') MergeRequest::Metrics.prepend_if_ee('EE::MergeRequest::Metrics')
...@@ -6,5 +6,11 @@ class MergeRequestAssignee < ApplicationRecord ...@@ -6,5 +6,11 @@ class MergeRequestAssignee < ApplicationRecord
validates :assignee, uniqueness: { scope: :merge_request_id } validates :assignee, uniqueness: { scope: :merge_request_id }
scope :preloaded, -> { preload(:assignee) }
scope :in_projects, ->(project_ids) { joins(:merge_request).where("merge_requests.target_project_id in (?)", project_ids) } scope :in_projects, ->(project_ids) { joins(:merge_request).where("merge_requests.target_project_id in (?)", project_ids) }
scope :for_merge_requests, ->(merge_request_scope) do
where(merge_request: merge_request_scope)
end
end end
...@@ -100,6 +100,10 @@ class MergeRequestDiff < ApplicationRecord ...@@ -100,6 +100,10 @@ class MergeRequestDiff < ApplicationRecord
joins(merge_request: :metrics).where(condition) joins(merge_request: :metrics).where(condition)
end end
scope :for_merge_requests, -> (merge_request_scope) do
where(merge_request: merge_request_scope)
end
def self.ids_for_external_storage_migration(limit:) def self.ids_for_external_storage_migration(limit:)
# No point doing any work unless the feature is enabled # No point doing any work unless the feature is enabled
return [] unless Gitlab.config.external_diffs.enabled return [] unless Gitlab.config.external_diffs.enabled
......
...@@ -20,6 +20,10 @@ class Timelog < ApplicationRecord ...@@ -20,6 +20,10 @@ class Timelog < ApplicationRecord
where('spent_at BETWEEN ? AND ?', start_time, end_time) where('spent_at BETWEEN ? AND ?', start_time, end_time)
end end
scope :for_merge_requests, -> (merge_request_scope) do
where(merge_request: merge_request_scope)
end
def issuable def issuable
issue || merge_request issue || merge_request
end end
......
...@@ -9,7 +9,7 @@ module EE ...@@ -9,7 +9,7 @@ module EE
resource :projects, requirements: ::API::API::NAMESPACE_OR_PROJECT_REQUIREMENTS do resource :projects, requirements: ::API::API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
desc 'Create Evidence for a Release' do desc 'Create Evidence for a Release' do
detail 'This feature was introduced in GitLab 12.10.' detail 'This feature was introduced in GitLab 12.10.'
success Entities::Release success ::API::Entities::Release
end end
params do params do
requires :tag_name, type: String, desc: 'The name of the tag', as: :tag requires :tag_name, type: String, desc: 'The name of the tag', as: :tag
......
...@@ -8,14 +8,26 @@ module API ...@@ -8,14 +8,26 @@ module API
expose :title, :description expose :title, :description
expose :state, :created_at, :updated_at expose :state, :created_at, :updated_at
# Avoids an N+1 query when metadata is included def presented
def issuable_metadata(subject, options, method, args = nil) lazy_issuable_metadata
cached_subject = options.dig(:issuable_metadata, subject.id)
if cached_subject super
cached_subject[method] end
else
subject.public_send(method, *args) # rubocop: disable GitlabSecurity/PublicSend def issuable_metadata
lazy_issuable_metadata
end
protected
def lazy_issuable_metadata
BatchLoader.for(object).batch(key: :issuable_metadata) do |models, loader|
issuable_metadata = Gitlab::IssuableMetadata.new(nil, models)
metadata_by_id = issuable_metadata.data
models.each do |issuable|
loader.call(issuable, metadata_by_id[issuable.id])
end
end end
end end
end end
......
...@@ -7,6 +7,12 @@ module API ...@@ -7,6 +7,12 @@ module API
Gitlab::TimeTrackingFormatter.output(time_spent) Gitlab::TimeTrackingFormatter.output(time_spent)
end end
def presented
lazy_timelogs
super
end
expose :time_estimate expose :time_estimate
expose :total_time_spent expose :total_time_spent
expose :human_time_estimate expose :human_time_estimate
...@@ -15,12 +21,19 @@ module API ...@@ -15,12 +21,19 @@ module API
expose :total_time_spent, as: :human_total_time_spent expose :total_time_spent, as: :human_total_time_spent
end end
# rubocop: disable CodeReuse/ActiveRecord private
def lazy_timelogs
BatchLoader.for(object.id).batch(key: :timelogs, default_value: []) do |ids, loader|
Timelog.for_merge_requests(ids).find_each do |timelog|
loader.call(timelog.merge_request_id) { |acc| acc << timelog }
end
end
end
def total_time_spent def total_time_spent
# Avoids an N+1 query since timelogs are preloaded lazy_timelogs.sum(&:time_spend) # rubocop:disable CodeReuse/ActiveRecord
object.timelogs.map(&:time_spent).sum
end end
# rubocop: enable CodeReuse/ActiveRecord
end end
end end
end end
...@@ -3,17 +3,35 @@ ...@@ -3,17 +3,35 @@
module API module API
module Entities module Entities
class MergeRequestBasic < IssuableEntity class MergeRequestBasic < IssuableEntity
# Evaluate the lazy exposures to trigger the BatchLoader
# before any object is serialized.
def presented
lazy_merge_request_metrics
lazy_diff
lazy_assignees
lazy_author
lazy_milestone
lazy_labels
# TODO: we could have a `:batch` exposure option to automatically scan
# exposures and evaluate the block so that the BatchLoader is primed
time_stats = self.class.find_exposure(:time_stats)
Object.const_get(time_stats.using_class_name, false).new(object).presented
super
end
expose :merged_by, using: Entities::UserBasic do |merge_request, _options| expose :merged_by, using: Entities::UserBasic do |merge_request, _options|
merge_request.metrics&.merged_by lazy_merge_request_metrics&.merged_by
end end
expose :merged_at do |merge_request, _options| expose :merged_at do |merge_request, _options|
merge_request.metrics&.merged_at lazy_merge_request_metrics&.merged_at
end end
expose :closed_by, using: Entities::UserBasic do |merge_request, _options| expose :closed_by, using: Entities::UserBasic do |merge_request, _options|
merge_request.metrics&.latest_closed_by lazy_merge_request_metrics&.latest_closed_by
end end
expose :closed_at do |merge_request, _options| expose :closed_at do |merge_request, _options|
merge_request.metrics&.latest_closed_at lazy_merge_request_metrics&.latest_closed_at
end end
expose :title_html, if: -> (_, options) { options[:render_html] } do |entity| expose :title_html, if: -> (_, options) { options[:render_html] } do |entity|
MarkupHelper.markdown_field(entity, :title) MarkupHelper.markdown_field(entity, :title)
...@@ -22,23 +40,30 @@ module API ...@@ -22,23 +40,30 @@ module API
MarkupHelper.markdown_field(entity, :description) MarkupHelper.markdown_field(entity, :description)
end end
expose :target_branch, :source_branch expose :target_branch, :source_branch
expose(:user_notes_count) { |merge_request, options| issuable_metadata(merge_request, options, :user_notes_count) } expose(:user_notes_count) { |merge_request, options| issuable_metadata.user_notes_count }
expose(:upvotes) { |merge_request, options| issuable_metadata(merge_request, options, :upvotes) } expose(:upvotes) { |merge_request, options| issuable_metadata.upvotes }
expose(:downvotes) { |merge_request, options| issuable_metadata(merge_request, options, :downvotes) } expose(:downvotes) { |merge_request, options| issuable_metadata.downvotes }
expose :assignee, using: ::API::Entities::UserBasic do |merge_request|
merge_request.assignee with_options using: Entities::UserBasic do
expose :lazy_author, as: :author
expose :lazy_assignees, as: :assignees
expose :lazy_assignee, as: :assignee do |merge_request, options|
lazy_assignees.first
end
end end
expose :author, :assignees, using: Entities::UserBasic
expose :source_project_id, :target_project_id expose :source_project_id, :target_project_id
expose :labels do |merge_request, options| expose :labels do |merge_request, options|
if options[:with_labels_details] lazy_labels do |label|
::API::Entities::LabelBasic.represent(merge_request.labels.sort_by(&:title)) if options[:with_labels_details]
else Entities::LabelBasic.new(label)
merge_request.labels.map(&:title).sort else
label.title
end
end end
end end
expose :work_in_progress?, as: :work_in_progress expose :work_in_progress?, as: :work_in_progress
expose :milestone, using: Entities::Milestone expose :lazy_milestone, as: :milestone, using: Entities::Milestone
expose :merge_when_pipeline_succeeds expose :merge_when_pipeline_succeeds
# Ideally we should deprecate `MergeRequest#merge_status` exposure and # Ideally we should deprecate `MergeRequest#merge_status` exposure and
...@@ -51,15 +76,20 @@ module API ...@@ -51,15 +76,20 @@ module API
merge_request.check_mergeability(async: true) unless options[:skip_merge_status_recheck] merge_request.check_mergeability(async: true) unless options[:skip_merge_status_recheck]
merge_request.public_merge_status merge_request.public_merge_status
end end
expose :diff_head_sha, as: :sha expose :diff_head_sha, as: :sha do |_, options|
lazy_diff.read_attribute(:head_commit_sha)
end
expose :merge_commit_sha expose :merge_commit_sha
expose :squash_commit_sha expose :squash_commit_sha
expose :discussion_locked expose :discussion_locked
expose :should_remove_source_branch?, as: :should_remove_source_branch expose :should_remove_source_branch?, as: :should_remove_source_branch
expose :force_remove_source_branch?, as: :force_remove_source_branch expose :force_remove_source_branch?, as: :force_remove_source_branch
expose :allow_collaboration, if: -> (merge_request, _) { merge_request.for_fork? }
# Deprecated with_options if: -> (merge_request, _) { merge_request.for_fork? } do
expose :allow_collaboration, as: :allow_maintainer_to_push, if: -> (merge_request, _) { merge_request.for_fork? } expose :allow_collaboration
# Deprecated
expose :allow_collaboration, as: :allow_maintainer_to_push
end
# reference is deprecated in favour of references # reference is deprecated in favour of references
# Introduced [Gitlab 12.6](https://gitlab.com/gitlab-org/gitlab/merge_requests/20354) # Introduced [Gitlab 12.6](https://gitlab.com/gitlab-org/gitlab/merge_requests/20354)
...@@ -83,6 +113,72 @@ module API ...@@ -83,6 +113,72 @@ module API
expose :task_completion_status expose :task_completion_status
expose :cannot_be_merged?, as: :has_conflicts expose :cannot_be_merged?, as: :has_conflicts
expose :mergeable_discussions_state?, as: :blocking_discussions_resolved expose :mergeable_discussions_state?, as: :blocking_discussions_resolved
private
def lazy_merge_request_metrics
BatchLoader.for(object.id).batch(key: :merge_request_metrics) do |models, loader|
::MergeRequest::Metrics
.preloaded
.for_merge_requests(models)
.find_each do |metric|
loader.call(metric.merge_request_id, metric)
end
end
end
def lazy_diff
BatchLoader.for(object.id).batch(key: :merge_request_diff) do |ids, loader|
::MergeRequestDiff
.for_merge_requests(ids)
.find_each do |diff|
loader.call(diff.merge_request_id, diff)
end
end
end
def lazy_assignees
BatchLoader.for(object.id).batch(key: :assignees, default_value: []) do |ids, loader|
::MergeRequestAssignee
.preloaded
.for_merge_requests(ids)
.find_each do |assignment|
loader.call(assignment.merge_request_id) { |acc| acc << assignment.assignee }
end
end
end
def lazy_author
BatchLoader.for(object.author_id).batch(key: :author) do |ids, loader|
::User.id_in(ids).find_each do |author|
loader.call(author.id, author)
end
end
end
def lazy_milestone
BatchLoader.for(object.milestone_id).batch(key: :milestone) do |ids, loader|
::Milestone
.with_api_entity_associations
.id_in(ids)
.find_each do |milestone|
loader.call(milestone.id, milestone)
end
end
end
def lazy_labels(&block)
BatchLoader.for(object.id).batch(key: :labels, default_value: []) do |ids, loader|
::LabelLink
.preloaded
.for_merge_requests(ids)
.find_each do |link|
loader.call(link.target_id) do |memo|
memo << yield(link.label)
end
end
end
end
end end
end end
end end
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe ::API::Entities::MergeRequestBasic do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, :public) }
let_it_be(:merge_request) { create(:merge_request) }
let_it_be(:labels) { create_list(:label, 3) }
let_it_be(:merge_requests) { create_list(:labeled_merge_request, 10, :unique_branches, :with_diffs, labels: labels) }
let(:scope) { MergeRequest.with_api_entity_associations }
# This mimics the behavior of the `Grape::Entity` serializer
def present(obj)
described_class.new(obj).presented
end
describe "#with_api_entity_associations" do
it "avoids N+1 queries" do
query = scope.find(merge_request.id)
control = ActiveRecord::QueryRecorder.new do
present(query).to_json
end
query = scope.all
batch = ActiveRecord::QueryRecorder.new do
entities = query.map(&method(:present))
entities.to_json
end
# The current threshold is 3 query per entity maximum.
expect(batch.count).to be_within(3 * query.count).of(control.count)
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