issuable_finder.rb 15.2 KB
Newer Older
1 2
# frozen_string_literal: true

3
# IssuableFinder
4 5 6 7 8 9 10
#
# 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:
11
#     scope: 'created_by_me' or 'assigned_to_me' or 'all'
12
#     state: 'opened' or 'closed' or 'locked' or 'all'
13 14
#     group_id: integer
#     project_id: integer
15
#     milestone_title: string
16
#     author_id: integer
17
#     author_username: string
18
#     assignee_id: integer or 'None' or 'Any'
19
#     assignee_username: string
20
#     search: string
Hiroyuki Sato's avatar
Hiroyuki Sato committed
21
#     in: 'title', 'description', or a string joining them with comma
22 23
#     label_name: string
#     sort: string
24
#     non_archived: boolean
25
#     iids: integer[]
26
#     my_reaction_emoji: string
27 28 29 30
#     created_after: datetime
#     created_before: datetime
#     updated_after: datetime
#     updated_before: datetime
31
#     attempt_group_search_optimizations: boolean
32
#
33
class IssuableFinder
34 35
  prepend FinderWithCrossProjectAccess
  include FinderMethods
36
  include CreatedAtFilter
37
  include Gitlab::Utils::StrongMemoize
38

39 40
  requires_cross_project_access unless: -> { project? }

41
  # This is used as a common filter for None / Any
42 43
  FILTER_NONE = 'none'.freeze
  FILTER_ANY = 'any'.freeze
44 45

  # This is accepted as a deprecated filter and is also used in unassigning users
46
  NONE = '0'.freeze
47

48
  attr_accessor :current_user, :params
49

50 51 52 53 54 55
  def self.scalar_params
    @scalar_params ||= %i[
      assignee_id
      assignee_username
      author_id
      author_username
56
      label_name
57 58 59
      milestone_title
      my_reaction_emoji
      search
60
      in
61 62 63 64
    ]
  end

  def self.array_params
65
    @array_params ||= { label_name: [], assignee_username: [] }
66 67 68 69 70 71
  end

  def self.valid_params
    @valid_params ||= scalar_params + [array_params]
  end

72
  def initialize(current_user, params = {})
73 74
    @current_user = current_user
    @params = params
75
  end
76

77
  def execute
78
    items = init_collection
79 80
    items = filter_items(items)

81 82 83
    # This has to be last as we use a CTE as an optimization fence
    # for counts by passing the force_cte param and enabling the
    # attempt_group_search_optimizations feature flag
84 85
    # https://www.postgresql.org/docs/current/static/queries-with.html
    items = by_search(items)
86

87
    items = sort(items)
88 89

    items
90 91 92
  end

  def filter_items(items)
93
    items = by_project(items)
94
    items = by_group(items)
95
    items = by_scope(items)
96
    items = by_created_at(items)
97
    items = by_updated_at(items)
98
    items = by_closed_at(items)
99 100 101
    items = by_state(items)
    items = by_group(items)
    items = by_assignee(items)
102
    items = by_author(items)
103
    items = by_non_archived(items)
104
    items = by_iids(items)
105 106
    items = by_milestone(items)
    items = by_label(items)
107
    by_my_reaction_emoji(items)
108 109
  end

110 111 112 113
  def row_count
    Gitlab::IssuablesCountForState.new(self).for_state_or_opened(params[:state])
  end

114 115 116 117 118
  # 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.
  #
119
  # rubocop: disable CodeReuse/ActiveRecord
120
  def count_by_state
121
    count_params = params.merge(state: nil, sort: nil, force_cte: true)
122
    finder = self.class.new(current_user, count_params)
123

124 125 126 127 128 129 130
    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.
    #
131 132
    # 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.
133
    #
134 135 136 137
    # Groups and projects have separate feature flags to suggest the use
    # of a CTE. The CTE will not be used if the sort doesn't support it,
    # but will always be used for the counts here as we ignore sorting
    # anyway.
138
    labels_count = label_names.any? ? label_names.count : 1
139
    labels_count = 1 if use_cte_for_search?
140

141
    finder.execute.reorder(nil).group(:state).count.each do |key, value|
142
      counts[count_key(key)] += value / labels_count
143 144 145 146
    end

    counts[:all] = counts.values.sum

147
    counts.with_indifferent_access
148
  end
149
  # rubocop: enable CodeReuse/ActiveRecord
150

151 152 153
  def group
    return @group if defined?(@group)

154
    @group =
155 156
      if params[:group_id].present?
        Group.find(params[:group_id])
157
      else
158 159 160 161
        nil
      end
  end

162 163 164 165 166 167
  def related_groups
    if project? && project && project.group && Ability.allowed?(current_user, :read_group, project.group)
      project.group.self_and_ancestors
    elsif group
      [group]
    elsif current_user
168
      Gitlab::ObjectHierarchy.new(current_user.authorized_groups, current_user.groups).all_objects
169 170 171 172 173
    else
      []
    end
  end

174 175 176 177
  def project?
    params[:project_id].present?
  end

178 179 180
  def project
    return @project if defined?(@project)

181 182
    project = Project.find(params[:project_id])
    project = nil unless Ability.allowed?(current_user, :"read_#{klass.to_ability_name}", project)
183

184
    @project = project
185 186
  end

187
  # rubocop: disable CodeReuse/ActiveRecord
188 189 190 191
  def projects
    return @projects if defined?(@projects)

    return @projects = [project] if project?
192 193 194 195 196

    projects =
      if current_user && params[:authorized_only].presence && !current_user_related?
        current_user.authorized_projects
      elsif group
197
        finder_options = { include_subgroups: params[:include_subgroups], only_owned: true }
198
        GroupProjectsFinder.new(group: group, current_user: current_user, options: finder_options).execute # rubocop: disable CodeReuse/Finder
199
      else
200
        ProjectsFinder.new(current_user: current_user).execute # rubocop: disable CodeReuse/Finder
201
      end
202

203
    @projects = projects.with_feature_available_for_user(klass, current_user).reorder(nil)
204
  end
205
  # rubocop: enable CodeReuse/ActiveRecord
206 207 208 209 210 211 212 213 214 215 216 217 218

  def search
    params[:search].presence
  end

  def milestones?
    params[:milestone_title].present?
  end

  def milestones
    return @milestones if defined?(@milestones)

    @milestones =
219
      if milestones?
Felipe Artur's avatar
Felipe Artur committed
220 221 222 223 224 225
        if project?
          group_id = project.group&.id
          project_id = project.id
        end

        group_id = group.id if group
226

Felipe Artur's avatar
Felipe Artur committed
227 228 229
        search_params =
          { title: params[:milestone_title], project_ids: project_id, group_ids: group_id }

230
        MilestonesFinder.new(search_params).execute # rubocop: disable CodeReuse/Finder
231
      else
232
        Milestone.none
233 234 235
      end
  end

236 237 238 239
  def labels?
    params[:label_name].present?
  end

Douwe Maan's avatar
Douwe Maan committed
240
  def filter_by_no_label?
241 242 243 244 245 246 247 248
    downcased = label_names.map(&:downcase)

    # Label::NONE is deprecated and should be removed in 12.0
    downcased.include?(FILTER_NONE) || downcased.include?(Label::NONE)
  end

  def filter_by_any_label?
    label_names.map(&:downcase).include?(FILTER_ANY)
249 250
  end

251 252 253
  def labels
    return @labels if defined?(@labels)

254 255
    @labels =
      if labels? && !filter_by_no_label?
256
        LabelsFinder.new(current_user, project_ids: projects, title: label_names).execute(skip_authorization: true) # rubocop: disable CodeReuse/Finder
257 258
      else
        Label.none
259 260 261
      end
  end

262
  def assignee_id?
263
    params[:assignee_id].present?
264 265
  end

266
  def assignee_username?
267
    params[:assignee_username].present?
268 269
  end

270
  # rubocop: disable CodeReuse/ActiveRecord
271 272 273
  def assignee
    return @assignee if defined?(@assignee)

274
    @assignee =
275
      if assignee_id?
276
        User.find_by(id: params[:assignee_id])
277
      elsif assignee_username?
278
        User.find_by_username(params[:assignee_username])
279 280 281 282
      else
        nil
      end
  end
283
  # rubocop: enable CodeReuse/ActiveRecord
284

285
  def author_id?
286
    params[:author_id].present? && params[:author_id] != NONE
287 288
  end

289
  def author_username?
290
    params[:author_username].present? && params[:author_username] != NONE
291 292
  end

293
  def no_author?
294
    # author_id takes precedence over author_username
295 296 297
    params[:author_id] == NONE || params[:author_username] == NONE
  end

298
  # rubocop: disable CodeReuse/ActiveRecord
299 300 301
  def author
    return @author if defined?(@author)

302
    @author =
303 304 305
      if author_id?
        User.find_by(id: params[:author_id])
      elsif author_username?
306
        User.find_by_username(params[:author_username])
307 308 309 310
      else
        nil
      end
  end
311
  # rubocop: enable CodeReuse/ActiveRecord
312

313 314 315 316 317 318
  def use_cte_for_search?
    strong_memoize(:use_cte_for_search) do
      next false unless search
      next false unless Gitlab::Database.postgresql?
      # Only simple unsorted & simple sorts can use CTE
      next false if params[:sort].present? && !params[:sort].in?(klass.simple_sorts.keys)
319

320
      attempt_group_search_optimizations? || attempt_project_search_optimizations?
321 322 323
    end
  end

324 325
  private

326 327 328 329
  def force_cte?
    !!params[:force_cte]
  end

330
  def init_collection
331
    klass.all
332 333
  end

334
  def attempt_group_search_optimizations?
335
    params[:attempt_group_search_optimizations] &&
336
      Feature.enabled?(:attempt_group_search_optimizations, default_enabled: true)
337 338
  end

339 340 341 342 343
  def attempt_project_search_optimizations?
    params[:attempt_project_search_optimizations] &&
      Feature.enabled?(:attempt_project_search_optimizations)
  end

344 345 346 347
  def count_key(value)
    Array(value).last.to_sym
  end

348
  # rubocop: disable CodeReuse/ActiveRecord
349
  def by_scope(items)
350 351
    return items.none if current_user_related? && !current_user

Douwe Maan's avatar
Douwe Maan committed
352
    case params[:scope]
353
    when 'created_by_me', 'authored'
354
      items.where(author_id: current_user.id)
355
    when 'assigned_to_me'
356
      items.assigned_to(current_user)
357
    else
Douwe Maan's avatar
Douwe Maan committed
358
      items
359 360
    end
  end
361
  # rubocop: enable CodeReuse/ActiveRecord
362

363 364 365 366 367 368 369
  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

370 371 372 373 374 375 376
  def by_closed_at(items)
    items = items.closed_after(params[:closed_after]) if params[:closed_after].present?
    items = items.closed_before(params[:closed_before]) if params[:closed_before].present?

    items
  end

377
  # rubocop: disable CodeReuse/ActiveRecord
378
  def by_state(items)
379 380 381 382 383 384 385
    case params[:state].to_s
    when 'closed'
      items.closed
    when 'merged'
      items.respond_to?(:merged) ? items.merged : items.closed
    when 'opened'
      items.opened
386 387
    when 'locked'
      items.where(state: 'locked')
388
    else
389
      items
390 391
    end
  end
392
  # rubocop: enable CodeReuse/ActiveRecord
393 394

  def by_group(items)
395
    # Selection by group is already covered by `by_project` and `projects`
396 397 398
    items
  end

399
  # rubocop: disable CodeReuse/ActiveRecord
400
  def by_project(items)
401
    items =
402
      if project?
403 404 405
        items.of_projects(projects).references_project
      elsif projects
        items.merge(projects.reorder(nil)).join_project
406 407 408
      else
        items.none
      end
409 410 411

    items
  end
412
  # rubocop: enable CodeReuse/ActiveRecord
413

414
  # rubocop: disable CodeReuse/ActiveRecord
415
  def by_search(items)
416 417
    return items unless search

418
    if use_cte_for_search?
419 420 421 422 423 424
      cte = Gitlab::SQL::RecursiveCTE.new(klass.table_name)
      cte << items

      items = klass.with(cte.to_arel).from(klass.table_name)
    end

425
    items.full_search(search, matched_columns: params[:in])
426
  end
427
  # rubocop: enable CodeReuse/ActiveRecord
428

429
  # rubocop: disable CodeReuse/ActiveRecord
430 431
  def by_iids(items)
    params[:iids].present? ? items.where(iid: params[:iids]) : items
432
  end
433
  # rubocop: enable CodeReuse/ActiveRecord
434

435
  # rubocop: disable CodeReuse/ActiveRecord
436
  def sort(items)
437 438
    # Ensure we always have an explicit sort order (instead of inheriting
    # multiple orders when combining ActiveRecord::Relation objects).
439
    params[:sort] ? items.sort_by_attribute(params[:sort], excluded_labels: label_names) : items.reorder(id: :desc)
440
  end
441
  # rubocop: enable CodeReuse/ActiveRecord
442

443 444 445 446 447 448 449 450 451
  def filter_by_no_assignee?
    # Assignee_id takes precedence over assignee_username
    [NONE, FILTER_NONE].include?(params[:assignee_id].to_s.downcase) || params[:assignee_username].to_s == NONE
  end

  def filter_by_any_assignee?
    params[:assignee_id].to_s.downcase == FILTER_ANY
  end

452
  # rubocop: disable CodeReuse/ActiveRecord
453
  def by_author(items)
454 455
    if author
      items = items.where(author_id: author.id)
456 457
    elsif no_author?
      items = items.where(author_id: nil)
458 459
    elsif author_id? || author_username? # author not found
      items = items.none
460 461 462 463
    end

    items
  end
464
  # rubocop: enable CodeReuse/ActiveRecord
465

466 467 468 469 470 471 472 473 474 475 476 477 478 479
  def by_assignee(items)
    if filter_by_no_assignee?
      items.unassigned
    elsif filter_by_any_assignee?
      items.assigned
    elsif assignee
      items.assigned_to(assignee)
    elsif assignee_id? || assignee_username? # assignee not found
      items.none
    else
      items
    end
  end

480
  # rubocop: disable CodeReuse/ActiveRecord
481 482
  def by_milestone(items)
    if milestones?
Douwe Maan's avatar
Douwe Maan committed
483
      if filter_by_no_milestone?
484
        items = items.left_joins_milestones.where(milestone_id: [-1, nil])
485 486
      elsif filter_by_any_milestone?
        items = items.any_milestone
Tiago Botelho's avatar
Tiago Botelho committed
487
      elsif filter_by_upcoming_milestone?
488
        upcoming_ids = Milestone.upcoming_ids(projects, related_groups)
489
        items = items.left_joins_milestones.where(milestone_id: upcoming_ids)
490
      elsif filter_by_started_milestone?
491
        items = items.left_joins_milestones.merge(Milestone.started)
492
      else
493
        items = items.with_milestone(params[:milestone_title])
494 495 496 497 498
      end
    end

    items
  end
499
  # rubocop: enable CodeReuse/ActiveRecord
500

501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518
  def filter_by_no_milestone?
    # Accepts `No Milestone` for compatibility
    params[:milestone_title].to_s.downcase == FILTER_NONE || params[:milestone_title] == Milestone::None.title
  end

  def filter_by_any_milestone?
    # Accepts `Any Milestone` for compatibility
    params[:milestone_title].to_s.downcase == FILTER_ANY || params[:milestone_title] == Milestone::Any.title
  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

519
  def by_label(items)
520 521 522
    return items unless labels?

    items =
Douwe Maan's avatar
Douwe Maan committed
523
      if filter_by_no_label?
524
        items.without_label
525 526
      elsif filter_by_any_label?
        items.any_label
527
      else
528
        items.with_label(label_names, params[:sort])
529
      end
530

531
    items
532
  end
533

534 535
  def by_my_reaction_emoji(items)
    if params[:my_reaction_emoji].present? && current_user
Heinrich Lee Yu's avatar
Heinrich Lee Yu committed
536 537 538 539 540 541 542 543
      items =
        if filter_by_no_reaction?
          items.not_awarded(current_user)
        elsif filter_by_any_reaction?
          items.awarded(current_user)
        else
          items.awarded(current_user, params[:my_reaction_emoji])
        end
544 545 546 547 548
    end

    items
  end

549 550 551 552 553 554 555 556
  def filter_by_no_reaction?
    params[:my_reaction_emoji].to_s.downcase == FILTER_NONE
  end

  def filter_by_any_reaction?
    params[:my_reaction_emoji].to_s.downcase == FILTER_ANY
  end

557
  def label_names
Thijs Wouters's avatar
Thijs Wouters committed
558 559 560 561 562
    if labels?
      params[:label_name].is_a?(String) ? params[:label_name].split(',') : params[:label_name]
    else
      []
    end
563 564
  end

565 566 567 568
  def by_non_archived(items)
    params[:non_archived].present? ? items.non_archived : items
  end

569
  def current_user_related?
570 571
    scope = params[:scope]
    scope == 'created_by_me' || scope == 'authored' || scope == 'assigned_to_me'
572
  end
573
end