Read about our upcoming Code of Conduct on this issue

issuable_finder.rb 15.4 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
mhasbini's avatar
mhasbini committed
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
#     attempt_project_search_optimizations: boolean
33
#
34
class IssuableFinder
35
36
  prepend FinderWithCrossProjectAccess
  include FinderMethods
37
  include CreatedAtFilter
38
  include Gitlab::Utils::StrongMemoize
39

40
41
  requires_cross_project_access unless: -> { project? }

42
  # This is used as a common filter for None / Any
Heinrich Lee Yu's avatar
Heinrich Lee Yu committed
43
44
  FILTER_NONE = 'none'.freeze
  FILTER_ANY = 'any'.freeze
45
46

  # This is accepted as a deprecated filter and is also used in unassigning users
Douwe Maan's avatar
Douwe Maan committed
47
  NONE = '0'.freeze
48

49
  attr_accessor :current_user, :params
50

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

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

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

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

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

82
83
84
    # 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
85
86
    # https://www.postgresql.org/docs/current/static/queries-with.html
    items = by_search(items)
87

88
    items = sort(items)
89
90

    items
91
92
93
  end

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

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

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

125
126
127
128
129
130
131
    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.
    #
132
133
    # 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.
134
    #
135
136
137
138
    # 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.
139
    labels_count = label_names.any? ? label_names.count : 1
140
    labels_count = 1 if use_cte_for_search?
141

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

    counts[:all] = counts.values.sum

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

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

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

163
164
165
166
167
168
  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
169
      Gitlab::ObjectHierarchy.new(current_user.authorized_groups, current_user.groups).all_objects
170
171
172
173
174
    else
      []
    end
  end

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

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

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

185
    @project = project
186
187
  end

188
189
190
191
  def projects
    return @projects if defined?(@projects)

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

    projects =
      if current_user && params[:authorized_only].presence && !current_user_related?
195
        current_user.authorized_projects(min_access_level)
196
      elsif group
197
        find_group_projects
198
      else
199
        Project.public_or_visible_to_user(current_user, min_access_level)
200
      end
201

202
203
204
205
206
207
208
209
210
211
212
    @projects = projects.with_feature_available_for_user(klass, current_user).reorder(nil) # rubocop: disable CodeReuse/ActiveRecord
  end

  def find_group_projects
    return Project.none unless group

    if params[:include_subgroups]
      Project.where(namespace_id: group.self_and_descendants) # rubocop: disable CodeReuse/ActiveRecord
    else
      group.projects
    end.public_or_visible_to_user(current_user, min_access_level)
213
214
215
216
217
218
219
220
221
222
223
224
225
226
  end

  def search
    params[:search].presence
  end

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

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

    @milestones =
227
      if milestones?
Felipe Artur's avatar
Felipe Artur committed
228
229
230
231
232
233
        if project?
          group_id = project.group&.id
          project_id = project.id
        end

        group_id = group.id if group
234

Felipe Artur's avatar
Felipe Artur committed
235
236
237
        search_params =
          { title: params[:milestone_title], project_ids: project_id, group_ids: group_id }

238
        MilestonesFinder.new(search_params).execute # rubocop: disable CodeReuse/Finder
239
      else
240
        Milestone.none
241
242
243
      end
  end

244
245
246
247
  def labels?
    params[:label_name].present?
  end

Douwe Maan's avatar
Douwe Maan committed
248
  def filter_by_no_label?
249
250
251
252
253
254
255
256
    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)
257
258
  end

Tap's avatar
Tap committed
259
260
261
  def labels
    return @labels if defined?(@labels)

262
263
    @labels =
      if labels? && !filter_by_no_label?
264
        LabelsFinder.new(current_user, project_ids: projects, title: label_names).execute(skip_authorization: true) # rubocop: disable CodeReuse/Finder
265
266
      else
        Label.none
Tap's avatar
Tap committed
267
268
269
      end
  end

270
  def assignee_id?
271
    params[:assignee_id].present?
272
273
  end

274
  def assignee_username?
275
    params[:assignee_username].present?
276
277
  end

278
  # rubocop: disable CodeReuse/ActiveRecord
279
280
281
  def assignee
    return @assignee if defined?(@assignee)

282
    @assignee =
Lin Jen-Shin's avatar
Lin Jen-Shin committed
283
      if assignee_id?
284
        User.find_by(id: params[:assignee_id])
Lin Jen-Shin's avatar
Lin Jen-Shin committed
285
      elsif assignee_username?
286
        User.find_by_username(params[:assignee_username])
287
288
289
290
      else
        nil
      end
  end
291
  # rubocop: enable CodeReuse/ActiveRecord
292

293
  def author_id?
Lin Jen-Shin's avatar
Lin Jen-Shin committed
294
    params[:author_id].present? && params[:author_id] != NONE
295
296
  end

297
  def author_username?
Lin Jen-Shin's avatar
Lin Jen-Shin committed
298
    params[:author_username].present? && params[:author_username] != NONE
299
300
  end

301
  def no_author?
Clement Ho's avatar
Clement Ho committed
302
    # author_id takes precedence over author_username
303
304
305
    params[:author_id] == NONE || params[:author_username] == NONE
  end

306
  # rubocop: disable CodeReuse/ActiveRecord
307
308
309
  def author
    return @author if defined?(@author)

310
    @author =
311
312
313
      if author_id?
        User.find_by(id: params[:author_id])
      elsif author_username?
314
        User.find_by_username(params[:author_username])
315
316
317
318
      else
        nil
      end
  end
319
  # rubocop: enable CodeReuse/ActiveRecord
320

321
322
323
324
325
326
  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)
327

328
      attempt_group_search_optimizations? || attempt_project_search_optimizations?
329
330
331
    end
  end

332
333
  private

334
335
336
337
  def force_cte?
    !!params[:force_cte]
  end

338
  def init_collection
339
    klass.all
340
341
  end

342
  def attempt_group_search_optimizations?
343
    params[:attempt_group_search_optimizations] &&
344
      Feature.enabled?(:attempt_group_search_optimizations, default_enabled: true)
345
346
  end

347
348
349
350
351
  def attempt_project_search_optimizations?
    params[:attempt_project_search_optimizations] &&
      Feature.enabled?(:attempt_project_search_optimizations)
  end

352
353
354
355
  def count_key(value)
    Array(value).last.to_sym
  end

356
  # rubocop: disable CodeReuse/ActiveRecord
357
  def by_scope(items)
358
359
    return items.none if current_user_related? && !current_user

Douwe Maan's avatar
Douwe Maan committed
360
    case params[:scope]
361
    when 'created_by_me', 'authored'
362
      items.where(author_id: current_user.id)
363
    when 'assigned_to_me'
364
      items.assigned_to(current_user)
365
    else
Douwe Maan's avatar
Douwe Maan committed
366
      items
367
368
    end
  end
369
  # rubocop: enable CodeReuse/ActiveRecord
370

371
372
373
374
375
376
377
  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

378
379
380
381
382
383
384
  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

385
  # rubocop: disable CodeReuse/ActiveRecord
386
  def by_state(items)
387
388
389
390
391
392
393
    case params[:state].to_s
    when 'closed'
      items.closed
    when 'merged'
      items.respond_to?(:merged) ? items.merged : items.closed
    when 'opened'
      items.opened
394
395
    when 'locked'
      items.where(state: 'locked')
396
    else
397
      items
398
399
    end
  end
400
  # rubocop: enable CodeReuse/ActiveRecord
401
402

  def by_group(items)
403
    # Selection by group is already covered by `by_project` and `projects`
404
405
406
    items
  end

407
  # rubocop: disable CodeReuse/ActiveRecord
408
  def by_project(items)
409
    items =
410
      if project?
411
412
413
        items.of_projects(projects).references_project
      elsif projects
        items.merge(projects.reorder(nil)).join_project
414
415
416
      else
        items.none
      end
417
418
419

    items
  end
420
  # rubocop: enable CodeReuse/ActiveRecord
421

422
  # rubocop: disable CodeReuse/ActiveRecord
423
  def by_search(items)
424
425
    return items unless search

426
    if use_cte_for_search?
427
428
429
430
431
432
      cte = Gitlab::SQL::RecursiveCTE.new(klass.table_name)
      cte << items

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

433
    items.full_search(search, matched_columns: params[:in])
mhasbini's avatar
mhasbini committed
434
  end
435
  # rubocop: enable CodeReuse/ActiveRecord
436

437
  # rubocop: disable CodeReuse/ActiveRecord
mhasbini's avatar
mhasbini committed
438
439
  def by_iids(items)
    params[:iids].present? ? items.where(iid: params[:iids]) : items
440
  end
441
  # rubocop: enable CodeReuse/ActiveRecord
442

443
  # rubocop: disable CodeReuse/ActiveRecord
444
  def sort(items)
445
446
    # Ensure we always have an explicit sort order (instead of inheriting
    # multiple orders when combining ActiveRecord::Relation objects).
447
    params[:sort] ? items.sort_by_attribute(params[:sort], excluded_labels: label_names) : items.reorder(id: :desc)
448
  end
449
  # rubocop: enable CodeReuse/ActiveRecord
450

451
452
453
454
455
456
457
458
459
  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

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

    items
  end
472
  # rubocop: enable CodeReuse/ActiveRecord
473

474
475
476
477
478
479
480
481
482
483
484
485
486
487
  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

488
  # rubocop: disable CodeReuse/ActiveRecord
489
490
  def by_milestone(items)
    if milestones?
Douwe Maan's avatar
Douwe Maan committed
491
      if filter_by_no_milestone?
492
        items = items.left_joins_milestones.where(milestone_id: [-1, nil])
493
494
      elsif filter_by_any_milestone?
        items = items.any_milestone
tiagonbotelho's avatar
tiagonbotelho committed
495
      elsif filter_by_upcoming_milestone?
496
        upcoming_ids = Milestone.upcoming_ids(projects, related_groups)
497
        items = items.left_joins_milestones.where(milestone_id: upcoming_ids)
498
      elsif filter_by_started_milestone?
499
        items = items.left_joins_milestones.merge(Milestone.started)
500
      else
501
        items = items.with_milestone(params[:milestone_title])
502
503
504
505
506
      end
    end

    items
  end
507
  # rubocop: enable CodeReuse/ActiveRecord
508

509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
  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

527
  def by_label(items)
528
529
530
    return items unless labels?

    items =
Douwe Maan's avatar
Douwe Maan committed
531
      if filter_by_no_label?
532
        items.without_label
533
534
      elsif filter_by_any_label?
        items.any_label
535
      else
536
        items.with_label(label_names, params[:sort])
537
      end
538

539
    items
540
  end
541

Hiroyuki Sato's avatar
Hiroyuki Sato committed
542
543
  def by_my_reaction_emoji(items)
    if params[:my_reaction_emoji].present? && current_user
Heinrich Lee Yu's avatar
Heinrich Lee Yu committed
544
545
546
547
548
549
550
551
      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
552
553
554
555
556
    end

    items
  end

557
558
559
560
561
562
563
564
  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
565
  def label_names
Thijs Wouters's avatar
Thijs Wouters committed
566
567
568
569
570
    if labels?
      params[:label_name].is_a?(String) ? params[:label_name].split(',') : params[:label_name]
    else
      []
    end
Tap's avatar
Tap committed
571
572
  end

573
574
575
576
  def by_non_archived(items)
    params[:non_archived].present? ? items.non_archived : items
  end

577
  def current_user_related?
578
579
    scope = params[:scope]
    scope == 'created_by_me' || scope == 'authored' || scope == 'assigned_to_me'
580
  end
581
582
583
584

  def min_access_level
    ProjectFeature.required_minimum_access_level(klass)
  end
585
end