c03386c391
When filtering issues with a search string in a group, we observed on GitLab.com that Postgres was using an inefficient query plan, preferring the (global) trigram indexes on description and title, rather than using a filter on the restricted set of issues within the group. Change the callers of the IssuableFinder to use a CTE in this case to fence the rest of the query from the LIKE filters, so that the optimiser is forced to perform the filter in the order we prefer. This will only force the use of a CTE when: 1. The use_cte_for_search params is truthy. 2. We are using Postgres. 3. We have passed the `search` param. The third item is important - searching issues using the search box does not use the finder in this way, but contructs a query and appends `full_search` to that. For some reason, this query does not suffer from the same issue. Currenly, we only pass this param when filtering issuables (issues or MRs) in a group context.
453 lines
11 KiB
Ruby
453 lines
11 KiB
Ruby
# IssuableFinder
|
|
#
|
|
# Used to filter Issues and MergeRequests collections by set of params
|
|
#
|
|
# Arguments:
|
|
# klass - actual class like Issue or MergeRequest
|
|
# current_user - which user use
|
|
# params:
|
|
# scope: 'created_by_me' or 'assigned_to_me' or 'all'
|
|
# state: 'opened' or 'closed' or 'all'
|
|
# group_id: integer
|
|
# project_id: integer
|
|
# milestone_title: string
|
|
# author_id: integer
|
|
# assignee_id: integer
|
|
# search: string
|
|
# label_name: string
|
|
# sort: string
|
|
# non_archived: boolean
|
|
# iids: integer[]
|
|
# my_reaction_emoji: string
|
|
# created_after: datetime
|
|
# created_before: datetime
|
|
# updated_after: datetime
|
|
# updated_before: datetime
|
|
# use_cte_for_search: boolean
|
|
#
|
|
class IssuableFinder
|
|
prepend FinderWithCrossProjectAccess
|
|
include FinderMethods
|
|
include CreatedAtFilter
|
|
|
|
requires_cross_project_access unless: -> { project? }
|
|
|
|
NONE = '0'.freeze
|
|
|
|
attr_accessor :current_user, :params
|
|
|
|
def self.scalar_params
|
|
@scalar_params ||= %i[
|
|
assignee_id
|
|
assignee_username
|
|
author_id
|
|
author_username
|
|
authorized_only
|
|
group_id
|
|
iids
|
|
label_name
|
|
milestone_title
|
|
my_reaction_emoji
|
|
non_archived
|
|
project_id
|
|
scope
|
|
search
|
|
sort
|
|
state
|
|
include_subgroups
|
|
use_cte_for_search
|
|
]
|
|
end
|
|
|
|
def self.array_params
|
|
@array_params ||= { label_name: [], iids: [], assignee_username: [] }
|
|
end
|
|
|
|
def self.valid_params
|
|
@valid_params ||= scalar_params + [array_params]
|
|
end
|
|
|
|
def initialize(current_user, params = {})
|
|
@current_user = current_user
|
|
@params = params
|
|
end
|
|
|
|
def execute
|
|
items = init_collection
|
|
items = filter_items(items)
|
|
|
|
# This has to be last as we may use a CTE as an optimization fence by
|
|
# passing the use_cte_for_search param
|
|
# https://www.postgresql.org/docs/current/static/queries-with.html
|
|
items = by_search(items)
|
|
|
|
sort(items)
|
|
end
|
|
|
|
def filter_items(items)
|
|
items = by_project(items)
|
|
items = by_scope(items)
|
|
items = by_created_at(items)
|
|
items = by_updated_at(items)
|
|
items = by_state(items)
|
|
items = by_group(items)
|
|
items = by_assignee(items)
|
|
items = by_author(items)
|
|
items = by_non_archived(items)
|
|
items = by_iids(items)
|
|
items = by_milestone(items)
|
|
items = by_label(items)
|
|
by_my_reaction_emoji(items)
|
|
end
|
|
|
|
def row_count
|
|
Gitlab::IssuablesCountForState.new(self).for_state_or_opened(params[:state])
|
|
end
|
|
|
|
# We often get counts for each state by running a query per state, and
|
|
# counting those results. This is typically slower than running one query
|
|
# (even if that query is slower than any of the individual state queries) and
|
|
# grouping and counting within that query.
|
|
#
|
|
def count_by_state
|
|
count_params = params.merge(state: nil, sort: nil)
|
|
finder = self.class.new(current_user, count_params)
|
|
counts = Hash.new(0)
|
|
|
|
# Searching by label includes a GROUP BY in the query, but ours will be last
|
|
# because it is added last. Searching by multiple labels also includes a row
|
|
# per issuable, so we have to count those in Ruby - which is bad, but still
|
|
# better than performing multiple queries.
|
|
#
|
|
# This does not apply when we are using a CTE for the search, as the labels
|
|
# GROUP BY is inside the subquery in that case, so we set labels_count to 1.
|
|
labels_count = label_names.any? ? label_names.count : 1
|
|
labels_count = 1 if use_cte_for_search?
|
|
|
|
finder.execute.reorder(nil).group(:state).count.each do |key, value|
|
|
counts[Array(key).last.to_sym] += value / labels_count
|
|
end
|
|
|
|
counts[:all] = counts.values.sum
|
|
|
|
counts
|
|
end
|
|
|
|
def group
|
|
return @group if defined?(@group)
|
|
|
|
@group =
|
|
if params[:group_id].present?
|
|
Group.find(params[:group_id])
|
|
else
|
|
nil
|
|
end
|
|
end
|
|
|
|
def project?
|
|
params[:project_id].present?
|
|
end
|
|
|
|
def project
|
|
return @project if defined?(@project)
|
|
|
|
project = Project.find(params[:project_id])
|
|
project = nil unless Ability.allowed?(current_user, :"read_#{klass.to_ability_name}", project)
|
|
|
|
@project = project
|
|
end
|
|
|
|
def projects(items = nil)
|
|
return @projects = project if project?
|
|
|
|
projects =
|
|
if current_user && params[:authorized_only].presence && !current_user_related?
|
|
current_user.authorized_projects
|
|
elsif group
|
|
finder_options = { include_subgroups: params[:include_subgroups], only_owned: true }
|
|
GroupProjectsFinder.new(group: group, current_user: current_user, options: finder_options).execute
|
|
else
|
|
ProjectsFinder.new(current_user: current_user).execute
|
|
end
|
|
|
|
@projects = projects.with_feature_available_for_user(klass, current_user).reorder(nil)
|
|
end
|
|
|
|
def search
|
|
params[:search].presence
|
|
end
|
|
|
|
def milestones?
|
|
params[:milestone_title].present?
|
|
end
|
|
|
|
def filter_by_no_milestone?
|
|
milestones? && params[:milestone_title] == Milestone::None.title
|
|
end
|
|
|
|
def milestones
|
|
return @milestones if defined?(@milestones)
|
|
|
|
@milestones =
|
|
if milestones?
|
|
if project?
|
|
group_id = project.group&.id
|
|
project_id = project.id
|
|
end
|
|
|
|
group_id = group.id if group
|
|
|
|
search_params =
|
|
{ title: params[:milestone_title], project_ids: project_id, group_ids: group_id }
|
|
|
|
MilestonesFinder.new(search_params).execute
|
|
else
|
|
Milestone.none
|
|
end
|
|
end
|
|
|
|
def labels?
|
|
params[:label_name].present?
|
|
end
|
|
|
|
def filter_by_no_label?
|
|
labels? && params[:label_name].include?(Label::None.title)
|
|
end
|
|
|
|
def labels
|
|
return @labels if defined?(@labels)
|
|
|
|
@labels =
|
|
if labels? && !filter_by_no_label?
|
|
LabelsFinder.new(current_user, project_ids: projects, title: label_names).execute(skip_authorization: true)
|
|
else
|
|
Label.none
|
|
end
|
|
end
|
|
|
|
def assignee_id?
|
|
params[:assignee_id].present? && params[:assignee_id] != NONE
|
|
end
|
|
|
|
def assignee_username?
|
|
params[:assignee_username].present? && params[:assignee_username] != NONE
|
|
end
|
|
|
|
def no_assignee?
|
|
# Assignee_id takes precedence over assignee_username
|
|
params[:assignee_id] == NONE || params[:assignee_username] == NONE
|
|
end
|
|
|
|
def assignee
|
|
return @assignee if defined?(@assignee)
|
|
|
|
@assignee =
|
|
if assignee_id?
|
|
User.find_by(id: params[:assignee_id])
|
|
elsif assignee_username?
|
|
User.find_by(username: params[:assignee_username])
|
|
else
|
|
nil
|
|
end
|
|
end
|
|
|
|
def author_id?
|
|
params[:author_id].present? && params[:author_id] != NONE
|
|
end
|
|
|
|
def author_username?
|
|
params[:author_username].present? && params[:author_username] != NONE
|
|
end
|
|
|
|
def no_author?
|
|
# author_id takes precedence over author_username
|
|
params[:author_id] == NONE || params[:author_username] == NONE
|
|
end
|
|
|
|
def author
|
|
return @author if defined?(@author)
|
|
|
|
@author =
|
|
if author_id?
|
|
User.find_by(id: params[:author_id])
|
|
elsif author_username?
|
|
User.find_by(username: params[:author_username])
|
|
else
|
|
nil
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
def init_collection
|
|
klass.all
|
|
end
|
|
|
|
def by_scope(items)
|
|
return items.none if current_user_related? && !current_user
|
|
|
|
case params[:scope]
|
|
when 'created_by_me', 'authored'
|
|
items.where(author_id: current_user.id)
|
|
when 'assigned_to_me'
|
|
items.assigned_to(current_user)
|
|
else
|
|
items
|
|
end
|
|
end
|
|
|
|
def by_updated_at(items)
|
|
items = items.updated_after(params[:updated_after]) if params[:updated_after].present?
|
|
items = items.updated_before(params[:updated_before]) if params[:updated_before].present?
|
|
|
|
items
|
|
end
|
|
|
|
def by_state(items)
|
|
case params[:state].to_s
|
|
when 'closed'
|
|
items.closed
|
|
when 'merged'
|
|
items.respond_to?(:merged) ? items.merged : items.closed
|
|
when 'opened'
|
|
items.opened
|
|
else
|
|
items
|
|
end
|
|
end
|
|
|
|
def by_group(items)
|
|
# Selection by group is already covered by `by_project` and `projects`
|
|
items
|
|
end
|
|
|
|
def by_project(items)
|
|
items =
|
|
if project?
|
|
items.of_projects(projects).references_project
|
|
elsif projects
|
|
items.merge(projects.reorder(nil)).join_project
|
|
else
|
|
items.none
|
|
end
|
|
|
|
items
|
|
end
|
|
|
|
def use_cte_for_search?
|
|
return false unless search
|
|
return false unless Gitlab::Database.postgresql?
|
|
|
|
params[:use_cte_for_search]
|
|
end
|
|
|
|
def by_search(items)
|
|
return items unless search
|
|
|
|
if use_cte_for_search?
|
|
cte = Gitlab::SQL::RecursiveCTE.new(klass.table_name)
|
|
cte << items
|
|
|
|
items = klass.with(cte.to_arel).from(klass.table_name)
|
|
end
|
|
|
|
items.full_search(search)
|
|
end
|
|
|
|
def by_iids(items)
|
|
params[:iids].present? ? items.where(iid: params[:iids]) : items
|
|
end
|
|
|
|
def sort(items)
|
|
# Ensure we always have an explicit sort order (instead of inheriting
|
|
# multiple orders when combining ActiveRecord::Relation objects).
|
|
params[:sort] ? items.sort_by_attribute(params[:sort], excluded_labels: label_names) : items.reorder(id: :desc)
|
|
end
|
|
|
|
def by_assignee(items)
|
|
if assignee
|
|
items = items.where(assignee_id: assignee.id)
|
|
elsif no_assignee?
|
|
items = items.where(assignee_id: nil)
|
|
elsif assignee_id? || assignee_username? # assignee not found
|
|
items = items.none
|
|
end
|
|
|
|
items
|
|
end
|
|
|
|
def by_author(items)
|
|
if author
|
|
items = items.where(author_id: author.id)
|
|
elsif no_author?
|
|
items = items.where(author_id: nil)
|
|
elsif author_id? || author_username? # author not found
|
|
items = items.none
|
|
end
|
|
|
|
items
|
|
end
|
|
|
|
def filter_by_upcoming_milestone?
|
|
params[:milestone_title] == Milestone::Upcoming.name
|
|
end
|
|
|
|
def filter_by_started_milestone?
|
|
params[:milestone_title] == Milestone::Started.name
|
|
end
|
|
|
|
def by_milestone(items)
|
|
if milestones?
|
|
if filter_by_no_milestone?
|
|
items = items.left_joins_milestones.where(milestone_id: [-1, nil])
|
|
elsif filter_by_upcoming_milestone?
|
|
upcoming_ids = Milestone.upcoming_ids_by_projects(projects(items))
|
|
items = items.left_joins_milestones.where(milestone_id: upcoming_ids)
|
|
elsif filter_by_started_milestone?
|
|
items = items.left_joins_milestones.where('milestones.start_date <= NOW()')
|
|
else
|
|
items = items.with_milestone(params[:milestone_title])
|
|
end
|
|
end
|
|
|
|
items
|
|
end
|
|
|
|
def by_label(items)
|
|
return items unless labels?
|
|
|
|
items =
|
|
if filter_by_no_label?
|
|
items.without_label
|
|
else
|
|
items.with_label(label_names, params[:sort])
|
|
end
|
|
|
|
items
|
|
end
|
|
|
|
def by_my_reaction_emoji(items)
|
|
if params[:my_reaction_emoji].present? && current_user
|
|
items = items.awarded(current_user, params[:my_reaction_emoji])
|
|
end
|
|
|
|
items
|
|
end
|
|
|
|
def label_names
|
|
if labels?
|
|
params[:label_name].is_a?(String) ? params[:label_name].split(',') : params[:label_name]
|
|
else
|
|
[]
|
|
end
|
|
end
|
|
|
|
def by_non_archived(items)
|
|
params[:non_archived].present? ? items.non_archived : items
|
|
end
|
|
|
|
def current_user_related?
|
|
scope = params[:scope]
|
|
scope == 'created_by_me' || scope == 'authored' || scope == 'assigned_to_me'
|
|
end
|
|
end
|