issuable_finder.rb 14.9 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
21
#     in: 'title', 'description' or a string joined them with comma
22 23
#     label_name: string
#     sort: string
24
#     non_archived: boolean
25
#     iids: integer[]
Hiroyuki Sato's avatar
Hiroyuki Sato committed
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
Douwe Maan's avatar
Douwe Maan committed
46
  NONE = '0'.freeze
47

48
  attr_accessor :current_user, :params
49

50 51 52 53 54 55 56 57 58 59
  def self.scalar_params
    @scalar_params ||= %i[
      assignee_id
      assignee_username
      author_id
      author_username
      label_name
      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 may use a CTE as an optimization fence
    # by passing the attempt_group_search_optimizations param and
    # enabling the use_cte_for_group_issues_search feature flag
84 85
    # https://www.postgresql.org/docs/current/static/queries-with.html
    items = by_search(items)
86 87 88 89 90

    sort(items)
  end

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

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

112 113 114 115 116
  # 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.
  #
117
  # rubocop: disable CodeReuse/ActiveRecord
118
  def count_by_state
119
    count_params = params.merge(state: nil, sort: nil)
120 121 122 123 124 125 126 127
    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.
    #
128 129 130 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.
    labels_count = label_names.any? ? label_names.count : 1
    labels_count = 1 if use_cte_for_search?

133
    finder.execute.reorder(nil).group(:state).count.each do |key, value|
134
      counts[count_key(key)] += value / labels_count
135 136 137 138
    end

    counts[:all] = counts.values.sum

139
    counts.with_indifferent_access
140
  end
141
  # rubocop: enable CodeReuse/ActiveRecord
142

143 144 145
  def group
    return @group if defined?(@group)

146
    @group =
147 148
      if params[:group_id].present?
        Group.find(params[:group_id])
149
      else
150 151 152 153
        nil
      end
  end

154 155 156 157 158 159
  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
160
      Gitlab::ObjectHierarchy.new(current_user.authorized_groups, current_user.groups).all_objects
161 162 163 164 165
    else
      []
    end
  end

166 167 168 169
  def project?
    params[:project_id].present?
  end

170 171 172
  def project
    return @project if defined?(@project)

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

176
    @project = project
177 178
  end

179
  # rubocop: disable CodeReuse/ActiveRecord
180 181 182 183
  def projects
    return @projects if defined?(@projects)

    return @projects = [project] if project?
184 185 186 187 188

    projects =
      if current_user && params[:authorized_only].presence && !current_user_related?
        current_user.authorized_projects
      elsif group
189
        finder_options = { include_subgroups: params[:include_subgroups], only_owned: true }
190
        GroupProjectsFinder.new(group: group, current_user: current_user, options: finder_options).execute # rubocop: disable CodeReuse/Finder
191
      else
192
        ProjectsFinder.new(current_user: current_user).execute # rubocop: disable CodeReuse/Finder
193
      end
194

195
    @projects = projects.with_feature_available_for_user(klass, current_user).reorder(nil)
196
  end
197
  # rubocop: enable CodeReuse/ActiveRecord
198 199 200 201 202 203 204 205 206 207 208 209 210

  def search
    params[:search].presence
  end

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

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

    @milestones =
211
      if milestones?
Felipe Artur's avatar
Felipe Artur committed
212 213 214 215 216 217
        if project?
          group_id = project.group&.id
          project_id = project.id
        end

        group_id = group.id if group
218

Felipe Artur's avatar
Felipe Artur committed
219 220 221
        search_params =
          { title: params[:milestone_title], project_ids: project_id, group_ids: group_id }

222
        MilestonesFinder.new(search_params).execute # rubocop: disable CodeReuse/Finder
223
      else
224
        Milestone.none
225 226 227
      end
  end

228 229 230 231
  def labels?
    params[:label_name].present?
  end

Douwe Maan's avatar
Douwe Maan committed
232
  def filter_by_no_label?
233 234 235 236 237 238 239 240
    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)
241 242
  end

Tap's avatar
Tap committed
243 244 245
  def labels
    return @labels if defined?(@labels)

246 247
    @labels =
      if labels? && !filter_by_no_label?
248
        LabelsFinder.new(current_user, project_ids: projects, title: label_names).execute(skip_authorization: true) # rubocop: disable CodeReuse/Finder
249 250
      else
        Label.none
Tap's avatar
Tap committed
251 252 253
      end
  end

254
  def assignee_id?
255
    params[:assignee_id].present?
256 257
  end

258
  def assignee_username?
259
    params[:assignee_username].present?
260 261
  end

262
  # rubocop: disable CodeReuse/ActiveRecord
263 264 265
  def assignee
    return @assignee if defined?(@assignee)

266
    @assignee =
Lin Jen-Shin's avatar
Lin Jen-Shin committed
267
      if assignee_id?
268
        User.find_by(id: params[:assignee_id])
Lin Jen-Shin's avatar
Lin Jen-Shin committed
269
      elsif assignee_username?
270
        User.find_by_username(params[:assignee_username])
271 272 273 274
      else
        nil
      end
  end
275
  # rubocop: enable CodeReuse/ActiveRecord
276

277
  def author_id?
Lin Jen-Shin's avatar
Lin Jen-Shin committed
278
    params[:author_id].present? && params[:author_id] != NONE
279 280
  end

281
  def author_username?
Lin Jen-Shin's avatar
Lin Jen-Shin committed
282
    params[:author_username].present? && params[:author_username] != NONE
283 284
  end

285
  def no_author?
286
    # author_id takes precedence over author_username
287 288 289
    params[:author_id] == NONE || params[:author_username] == NONE
  end

290
  # rubocop: disable CodeReuse/ActiveRecord
291 292 293
  def author
    return @author if defined?(@author)

294
    @author =
295 296 297
      if author_id?
        User.find_by(id: params[:author_id])
      elsif author_username?
298
        User.find_by_username(params[:author_username])
299 300 301 302
      else
        nil
      end
  end
303
  # rubocop: enable CodeReuse/ActiveRecord
304

305 306
  def use_subquery_for_search?
    strong_memoize(:use_subquery_for_search) do
307
      attempt_group_search_optimizations? &&
308 309 310 311 312 313
        Feature.enabled?(:use_subquery_for_group_issues_search, default_enabled: false)
    end
  end

  def use_cte_for_search?
    strong_memoize(:use_cte_for_search) do
Douwe Maan's avatar
Douwe Maan committed
314
      attempt_group_search_optimizations? &&
315
        !use_subquery_for_search? &&
316 317 318 319
        Feature.enabled?(:use_cte_for_group_issues_search, default_enabled: true)
    end
  end

320 321
  private

322
  def init_collection
323
    klass.all
324 325
  end

326 327 328 329
  def attempt_group_search_optimizations?
    search && Gitlab::Database.postgresql? && params[:attempt_group_search_optimizations]
  end

330 331 332 333
  def count_key(value)
    Array(value).last.to_sym
  end

334
  # rubocop: disable CodeReuse/ActiveRecord
335
  def by_scope(items)
336 337
    return items.none if current_user_related? && !current_user

Douwe Maan's avatar
Douwe Maan committed
338
    case params[:scope]
339
    when 'created_by_me', 'authored'
340
      items.where(author_id: current_user.id)
341
    when 'assigned_to_me'
342
      items.assigned_to(current_user)
343
    else
Douwe Maan's avatar
Douwe Maan committed
344
      items
345 346
    end
  end
347
  # rubocop: enable CodeReuse/ActiveRecord
348

349 350 351 352 353 354 355
  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

356
  # rubocop: disable CodeReuse/ActiveRecord
357
  def by_state(items)
358 359 360 361 362 363 364
    case params[:state].to_s
    when 'closed'
      items.closed
    when 'merged'
      items.respond_to?(:merged) ? items.merged : items.closed
    when 'opened'
      items.opened
365 366
    when 'locked'
      items.where(state: 'locked')
367
    else
368
      items
369 370
    end
  end
371
  # rubocop: enable CodeReuse/ActiveRecord
372 373

  def by_group(items)
374
    # Selection by group is already covered by `by_project` and `projects`
375 376 377
    items
  end

378
  # rubocop: disable CodeReuse/ActiveRecord
379
  def by_project(items)
380
    items =
381
      if project?
382 383 384
        items.of_projects(projects).references_project
      elsif projects
        items.merge(projects.reorder(nil)).join_project
385 386 387
      else
        items.none
      end
388 389 390

    items
  end
391
  # rubocop: enable CodeReuse/ActiveRecord
392

393 394 395 396 397 398 399
  # Wrap projects and groups in a subquery if the conditions are met.
  def by_subquery(items)
    if use_subquery_for_search?
      klass.where(id: items.select(:id)) # rubocop: disable CodeReuse/ActiveRecord
    else
      items
    end
400 401
  end

402
  # rubocop: disable CodeReuse/ActiveRecord
403
  def by_search(items)
404 405 406 407 408 409 410 411 412
    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

413
    items.full_search(search, matched_columns: params[:in])
414
  end
415
  # rubocop: enable CodeReuse/ActiveRecord
416

417
  # rubocop: disable CodeReuse/ActiveRecord
418 419
  def by_iids(items)
    params[:iids].present? ? items.where(iid: params[:iids]) : items
420
  end
421
  # rubocop: enable CodeReuse/ActiveRecord
422

423
  # rubocop: disable CodeReuse/ActiveRecord
424
  def sort(items)
425 426
    # Ensure we always have an explicit sort order (instead of inheriting
    # multiple orders when combining ActiveRecord::Relation objects).
427
    params[:sort] ? items.sort_by_attribute(params[:sort], excluded_labels: label_names) : items.reorder(id: :desc)
428
  end
429
  # rubocop: enable CodeReuse/ActiveRecord
430

431
  # rubocop: disable CodeReuse/ActiveRecord
432
  def by_assignee(items)
433 434 435 436 437 438
    if filter_by_no_assignee?
      items.where(assignee_id: nil)
    elsif filter_by_any_assignee?
      items.where('assignee_id IS NOT NULL')
    elsif assignee
      items.where(assignee_id: assignee.id)
439
    elsif assignee_id? || assignee_username? # assignee not found
440 441 442
      items.none
    else
      items
443 444
    end
  end
445
  # rubocop: enable CodeReuse/ActiveRecord
446

447 448 449 450 451 452 453 454 455
  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

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

    items
  end
468
  # rubocop: enable CodeReuse/ActiveRecord
469

470
  # rubocop: disable CodeReuse/ActiveRecord
471 472
  def by_milestone(items)
    if milestones?
Douwe Maan's avatar
Douwe Maan committed
473
      if filter_by_no_milestone?
474
        items = items.left_joins_milestones.where(milestone_id: [-1, nil])
475 476
      elsif filter_by_any_milestone?
        items = items.any_milestone
tiagonbotelho's avatar
tiagonbotelho committed
477
      elsif filter_by_upcoming_milestone?
478
        upcoming_ids = Milestone.upcoming_ids(projects, related_groups)
479
        items = items.left_joins_milestones.where(milestone_id: upcoming_ids)
480 481
      elsif filter_by_started_milestone?
        items = items.left_joins_milestones.where('milestones.start_date <= NOW()')
482
      else
483
        items = items.with_milestone(params[:milestone_title])
484 485 486 487 488
      end
    end

    items
  end
489
  # rubocop: enable CodeReuse/ActiveRecord
490

491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508
  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

509
  def by_label(items)
510 511 512
    return items unless labels?

    items =
Douwe Maan's avatar
Douwe Maan committed
513
      if filter_by_no_label?
514
        items.without_label
515 516
      elsif filter_by_any_label?
        items.any_label
517
      else
518
        items.with_label(label_names, params[:sort])
519
      end
520

521
    items
522
  end
523

Hiroyuki Sato's avatar
Hiroyuki Sato committed
524 525
  def by_my_reaction_emoji(items)
    if params[:my_reaction_emoji].present? && current_user
Heinrich Lee Yu's avatar
Heinrich Lee Yu committed
526 527 528 529 530 531 532 533
      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
Hiroyuki Sato's avatar
Hiroyuki Sato committed
534 535 536 537 538
    end

    items
  end

539 540 541 542 543 544 545 546
  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

Tap's avatar
Tap committed
547
  def label_names
Thijs Wouters's avatar
Thijs Wouters committed
548 549 550 551 552
    if labels?
      params[:label_name].is_a?(String) ? params[:label_name].split(',') : params[:label_name]
    else
      []
    end
Tap's avatar
Tap committed
553 554
  end

555 556 557 558
  def by_non_archived(items)
    params[:non_archived].present? ? items.non_archived : items
  end

559
  def current_user_related?
560 561
    scope = params[:scope]
    scope == 'created_by_me' || scope == 'authored' || scope == 'assigned_to_me'
562
  end
563
end