Commit 2353e28f authored by Tiger Watson's avatar Tiger Watson

Merge branch '233017-preload-associations-in-graphql-vulnerability-type' into 'master'

Preload all associations in Vulnerability GraphQL API

See merge request gitlab-org/gitlab!38556
parents 172cebde 4829a759
...@@ -8,6 +8,7 @@ module EE ...@@ -8,6 +8,7 @@ module EE
lazy_resolve ::Gitlab::Graphql::Aggregations::Epics::LazyEpicAggregate, :epic_aggregate lazy_resolve ::Gitlab::Graphql::Aggregations::Epics::LazyEpicAggregate, :epic_aggregate
lazy_resolve ::Gitlab::Graphql::Aggregations::Issues::LazyBlockAggregate, :block_aggregate lazy_resolve ::Gitlab::Graphql::Aggregations::Issues::LazyBlockAggregate, :block_aggregate
lazy_resolve ::Gitlab::Graphql::Aggregations::VulnerabilityStatistics::LazyAggregate, :aggregate lazy_resolve ::Gitlab::Graphql::Aggregations::VulnerabilityStatistics::LazyAggregate, :aggregate
lazy_resolve ::Gitlab::Graphql::Aggregations::Vulnerabilities::LazyUserNotesCountAggregate, :user_notes_count_aggregate
end end
end end
end end
...@@ -9,10 +9,10 @@ module Resolvers ...@@ -9,10 +9,10 @@ module Resolvers
required: false, required: false,
description: 'Filter issue links by link type' description: 'Filter issue links by link type'
delegate :issue_links, :finding, to: :object, private: true delegate :issue_links, :finding, :created_issue_links, to: :object, private: true
def resolve(link_type: nil, **) def resolve(link_type: nil, **)
links = issue_links.by_link_type(link_type) links = issue_links_by_link_type(link_type)
return links if links.present? || link_type != 'created' return links if links.present? || link_type != 'created'
issue_feedback = finding.issue_feedback issue_feedback = finding.issue_feedback
...@@ -20,6 +20,17 @@ module Resolvers ...@@ -20,6 +20,17 @@ module Resolvers
issue_links.build(issue_id: issue_feedback.id, link_type: :created) issue_links.build(issue_id: issue_feedback.id, link_type: :created)
end end
private
def issue_links_by_link_type(link_type)
case link_type.to_s
when Types::Vulnerability::IssueLinkTypeEnum.enum['created']
created_issue_links
else
issue_links.by_link_type(link_type)
end
end
end end
end end
end end
...@@ -29,7 +29,10 @@ module Resolvers ...@@ -29,7 +29,10 @@ module Resolvers
def resolve(**args) def resolve(**args)
return Vulnerability.none unless vulnerable return Vulnerability.none unless vulnerable
vulnerabilities(args).with_findings_and_scanner.ordered vulnerabilities(args)
.with_findings_scanner_and_identifiers
.with_created_issue_links_and_issues
.ordered
end end
private private
......
...@@ -31,7 +31,10 @@ module Types ...@@ -31,7 +31,10 @@ module Types
description: "Indicates whether the vulnerability is fixed on the default branch or not" description: "Indicates whether the vulnerability is fixed on the default branch or not"
field :user_notes_count, GraphQL::INT_TYPE, null: false, field :user_notes_count, GraphQL::INT_TYPE, null: false,
description: 'Number of user notes attached to the vulnerability' description: 'Number of user notes attached to the vulnerability',
resolve: -> (obj, _args, ctx) {
::Gitlab::Graphql::Aggregations::Vulnerabilities::LazyUserNotesCountAggregate.new(ctx, obj)
}
field :vulnerability_path, GraphQL::STRING_TYPE, null: true, field :vulnerability_path, GraphQL::STRING_TYPE, null: true,
description: "URL to the vulnerability's details page", description: "URL to the vulnerability's details page",
......
...@@ -13,6 +13,11 @@ module EE ...@@ -13,6 +13,11 @@ module EE
scope :searchable, -> { where(system: false).includes(:noteable) } scope :searchable, -> { where(system: false).includes(:noteable) }
scope :by_humans, -> { user.joins(:author).merge(::User.humans) } scope :by_humans, -> { user.joins(:author).merge(::User.humans) }
scope :with_suggestions, -> { joins(:suggestions) } scope :with_suggestions, -> { joins(:suggestions) }
scope :count_for_vulnerability_id, ->(vulnerability_id) do
where(noteable_type: Vulnerability.name, noteable_id: vulnerability_id)
.group(:noteable_id)
.count
end
end end
# Original method in Elastic::ApplicationSearch # Original method in Elastic::ApplicationSearch
......
...@@ -292,6 +292,12 @@ module Vulnerabilities ...@@ -292,6 +292,12 @@ module Vulnerabilities
# Array.difference (-) method uses hash and eql? methods to do comparison # Array.difference (-) method uses hash and eql? methods to do comparison
def hash def hash
# This is causing N+1 queries whenever we are calling findings, ActiveRecord uses #hash method to make sure the
# array with findings is uniq before preloading. This method is used only in Gitlab::Ci::Reports::Security::VulnerabilityReportsComparer
# where we are normalizing security report findings into instances of Vulnerabilities::Finding, this is why we are using original implementation
# when Finding is persisted and identifiers are not preloaded.
return super if persisted? && !identifiers.loaded?
report_type.hash ^ location_fingerprint.hash ^ first_fingerprint.hash report_type.hash ^ location_fingerprint.hash ^ first_fingerprint.hash
end end
......
...@@ -35,6 +35,7 @@ class Vulnerability < ApplicationRecord ...@@ -35,6 +35,7 @@ class Vulnerability < ApplicationRecord
has_many :findings, class_name: 'Vulnerabilities::Finding', inverse_of: :vulnerability has_many :findings, class_name: 'Vulnerabilities::Finding', inverse_of: :vulnerability
has_many :issue_links, class_name: 'Vulnerabilities::IssueLink', inverse_of: :vulnerability has_many :issue_links, class_name: 'Vulnerabilities::IssueLink', inverse_of: :vulnerability
has_many :created_issue_links, -> { created }, class_name: 'Vulnerabilities::IssueLink', inverse_of: :vulnerability
has_many :related_issues, through: :issue_links, source: :issue do has_many :related_issues, through: :issue_links, source: :issue do
def with_vulnerability_links def with_vulnerability_links
select('issues.*, vulnerability_issue_links.id AS vulnerability_link_id, '\ select('issues.*, vulnerability_issue_links.id AS vulnerability_link_id, '\
...@@ -62,6 +63,8 @@ class Vulnerability < ApplicationRecord ...@@ -62,6 +63,8 @@ class Vulnerability < ApplicationRecord
scope :with_findings, -> { includes(:findings) } scope :with_findings, -> { includes(:findings) }
scope :with_findings_and_scanner, -> { includes(findings: :scanner) } scope :with_findings_and_scanner, -> { includes(findings: :scanner) }
scope :with_findings_scanner_and_identifiers, -> { includes(findings: [:scanner, :identifiers, finding_identifiers: :identifier]) }
scope :with_created_issue_links_and_issues, -> { includes(created_issue_links: :issue) }
scope :for_projects, -> (project_ids) { where(project_id: project_ids) } scope :for_projects, -> (project_ids) { where(project_id: project_ids) }
scope :with_report_types, -> (report_types) { where(report_type: report_types) } scope :with_report_types, -> (report_types) { where(report_type: report_types) }
......
---
title: Preload all associations in Vulnerability GraphQL API
merge_request: 38556
author:
type: performance
# frozen_string_literal: true
module Gitlab
module Graphql
module Aggregations
module Vulnerabilities
class LazyUserNotesCountAggregate
attr_reader :vulnerability, :lazy_state
def initialize(query_ctx, vulnerability)
@vulnerability = vulnerability.respond_to?(:sync) ? vulnerability.sync : vulnerability
# Initialize the loading state for this query,
# or get the previously-initiated state
@lazy_state = query_ctx[:lazy_aggregate] ||= {
pending_vulnerability_ids: Set.new,
loaded_objects: {}
}
# Register this ID to be loaded later:
@lazy_state[:pending_vulnerability_ids] << vulnerability.id
end
# Return the loaded record, hitting the database if needed
def user_notes_count_aggregate
# Check if the record was already loaded
if @lazy_state[:pending_vulnerability_ids].present?
load_records_into_loaded_objects
end
@lazy_state[:loaded_objects][@vulnerability.id]
end
private
def load_records_into_loaded_objects
# The record hasn't been loaded yet, so
# hit the database with all pending IDs to prevent N+1
pending_vulnerability_ids = @lazy_state[:pending_vulnerability_ids].to_a
counts = ::Note.user.count_for_vulnerability_id(pending_vulnerability_ids)
pending_vulnerability_ids.each do |vulnerability_id|
@lazy_state[:loaded_objects][vulnerability_id] = counts[vulnerability_id].to_i
end
@lazy_state[:pending_vulnerability_ids].clear
end
end
end
end
end
end
...@@ -80,6 +80,20 @@ RSpec.describe Resolvers::VulnerabilitiesResolver do ...@@ -80,6 +80,20 @@ RSpec.describe Resolvers::VulnerabilitiesResolver do
it 'only returns vulnerabilities belonging to the given projects' do it 'only returns vulnerabilities belonging to the given projects' do
is_expected.to contain_exactly(project2_vulnerability) is_expected.to contain_exactly(project2_vulnerability)
end end
context 'with multiple project IDs' do
let(:filters) { { project_id: [project.id, project2.id] } }
it 'avoids N+1 queries' do
control_count = ActiveRecord::QueryRecorder.new do
resolve(described_class, obj: vulnerable, args: { project_id: [project2.id] }, ctx: { current_user: current_user })
end.count
expect do
subject
end.not_to exceed_query_limit(control_count)
end
end
end end
context 'when resolving vulnerabilities for a project' do context 'when resolving vulnerabilities for a project' do
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::Graphql::Aggregations::Vulnerabilities::LazyUserNotesCountAggregate do
let(:query_ctx) do
{}
end
let(:vulnerability) { create(:vulnerability) }
describe '#initialize' do
it 'adds the vulnerability to the lazy state' do
subject = described_class.new(query_ctx, vulnerability)
expect(subject.lazy_state[:pending_vulnerability_ids]).to match [vulnerability.id]
expect(subject.vulnerability).to match vulnerability
end
end
describe '#user_notes_count_aggregate' do
subject { described_class.new(query_ctx, vulnerability) }
before do
subject.instance_variable_set(:@lazy_state, fake_state)
end
context 'if the record has already been loaded' do
let(:fake_state) do
{ pending_vulnerability_ids: Set.new, loaded_objects: { vulnerability.id => 10 } }
end
it 'does not make the query again' do
expect(::Note).not_to receive(:user)
subject.user_notes_count_aggregate
end
end
context 'if the record has not been loaded' do
let(:other_vulnerability) { create(:vulnerability) }
let(:fake_state) do
{ pending_vulnerability_ids: Set.new([vulnerability.id, other_vulnerability.id]), loaded_objects: {} }
end
let(:fake_data) do
{
vulnerability.id => 10,
other_vulnerability.id => 14
}
end
before do
allow(::Note).to receive_message_chain(:user, :count_for_vulnerability_id).and_return(fake_data)
end
it 'makes the query' do
expect(::Note).to receive_message_chain(:user, :count_for_vulnerability_id).with([vulnerability.id, other_vulnerability.id])
subject.user_notes_count_aggregate
end
it 'clears the pending IDs' do
subject.user_notes_count_aggregate
expect(subject.lazy_state[:pending_vulnerability_ids]).to be_empty
end
end
end
end
...@@ -145,4 +145,17 @@ RSpec.describe Note do ...@@ -145,4 +145,17 @@ RSpec.describe Note do
expect(described_class.by_humans).to match_array([user_note]) expect(described_class.by_humans).to match_array([user_note])
end end
end end
describe '.count_for_vulnerability_id' do
it 'counts notes by vulnerability id' do
vulnerability_1 = create(:vulnerability)
vulnerability_2 = create(:vulnerability)
create(:note, noteable: vulnerability_1, project: vulnerability_1.project)
create(:note, noteable: vulnerability_2, project: vulnerability_2.project)
create(:note, noteable: vulnerability_2, project: vulnerability_2.project)
expect(described_class.count_for_vulnerability_id([vulnerability_1.id, vulnerability_2.id])).to eq(vulnerability_1.id => 1, vulnerability_2.id => 2)
end
end
end end
...@@ -34,6 +34,7 @@ RSpec.describe Vulnerability do ...@@ -34,6 +34,7 @@ RSpec.describe Vulnerability do
it { is_expected.to belong_to(:epic) } it { is_expected.to belong_to(:epic) }
it { is_expected.to have_many(:findings).class_name('Vulnerabilities::Finding').inverse_of(:vulnerability) } it { is_expected.to have_many(:findings).class_name('Vulnerabilities::Finding').inverse_of(:vulnerability) }
it { is_expected.to have_many(:issue_links).class_name('Vulnerabilities::IssueLink').inverse_of(:vulnerability) } it { is_expected.to have_many(:issue_links).class_name('Vulnerabilities::IssueLink').inverse_of(:vulnerability) }
it { is_expected.to have_many(:created_issue_links).class_name('Vulnerabilities::IssueLink').inverse_of(:vulnerability).conditions(link_type: Vulnerabilities::IssueLink.link_types['created']) }
it { is_expected.to have_many(:related_issues).through(:issue_links).source(:issue) } it { is_expected.to have_many(:related_issues).through(:issue_links).source(:issue) }
it { is_expected.to belong_to(:author).class_name('User') } it { is_expected.to belong_to(:author).class_name('User') }
it { is_expected.to belong_to(:updated_by).class_name('User') } it { is_expected.to belong_to(:updated_by).class_name('User') }
......
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