1# frozen_string_literal: true
2
3# WARNING: This finder does not check permissions!
4#
5# Arguments:
6#   params:
7#     project: Project model - Find deployments for this project
8#     updated_after: DateTime
9#     updated_before: DateTime
10#     finished_after: DateTime
11#     finished_before: DateTime
12#     environment: String
13#     status: String (see Deployment.statuses)
14#     order_by: String (see ALLOWED_SORT_VALUES constant)
15#     sort: String (asc | desc)
16class DeploymentsFinder
17  attr_reader :params
18
19  # Warning:
20  # These const are directly used in Deployment Rest API, thus
21  # modifying these values could implicity change the API interface or introduce a breaking change.
22  # Also, if you add a sort value, make sure that the new query will stay
23  # performant with the other filtering/sorting parameters.
24  # The composed query could be significantly slower when the filtering and sorting columns are different.
25  # See https://gitlab.com/gitlab-org/gitlab/-/issues/325627 for example.
26  ALLOWED_SORT_VALUES = %w[id iid created_at updated_at ref finished_at].freeze
27  DEFAULT_SORT_VALUE = 'id'
28
29  ALLOWED_SORT_DIRECTIONS = %w[asc desc].freeze
30  DEFAULT_SORT_DIRECTION = 'asc'
31
32  InefficientQueryError = Class.new(StandardError)
33
34  def initialize(params = {})
35    @params = params
36
37    validate!
38  end
39
40  def execute
41    items = init_collection
42    items = by_updated_at(items)
43    items = by_finished_at(items)
44    items = by_environment(items)
45    items = by_status(items)
46    items = preload_associations(items)
47    sort(items)
48  end
49
50  private
51
52  def validate!
53    if filter_by_updated_at? && filter_by_finished_at?
54      raise InefficientQueryError, 'Both `updated_at` filter and `finished_at` filter can not be specified'
55    end
56
57    # Currently, the inefficient parameters are allowed in order to avoid breaking changes in Deployment API.
58    # We'll switch to a hard error in https://gitlab.com/gitlab-org/gitlab/-/issues/328500.
59    if (filter_by_updated_at? && !order_by_updated_at?) || (!filter_by_updated_at? && order_by_updated_at?)
60      Gitlab::ErrorTracking.track_and_raise_for_dev_exception(
61        InefficientQueryError.new('`updated_at` filter and `updated_at` sorting must be paired')
62      )
63    end
64
65    if (filter_by_finished_at? && !order_by_finished_at?) || (!filter_by_finished_at? && order_by_finished_at?)
66      raise InefficientQueryError, '`finished_at` filter and `finished_at` sorting must be paired'
67    end
68
69    if filter_by_finished_at? && !filter_by_successful_deployment?
70      raise InefficientQueryError, '`finished_at` filter must be combined with `success` status filter.'
71    end
72
73    if params[:environment].present? && !params[:project].present?
74      raise InefficientQueryError, '`environment` filter must be combined with `project` scope.'
75    end
76  end
77
78  def init_collection
79    if params[:project].present?
80      params[:project].deployments
81    elsif params[:group].present?
82      ::Deployment.for_projects(params[:group].all_projects)
83    else
84      ::Deployment.none
85    end
86  end
87
88  def sort(items)
89    sort_params = build_sort_params
90    optimize_sort_params!(sort_params)
91    items.order(sort_params) # rubocop: disable CodeReuse/ActiveRecord
92  end
93
94  def by_updated_at(items)
95    items = items.updated_before(params[:updated_before]) if params[:updated_before].present?
96    items = items.updated_after(params[:updated_after]) if params[:updated_after].present?
97
98    items
99  end
100
101  def by_finished_at(items)
102    items = items.finished_before(params[:finished_before]) if params[:finished_before].present?
103    items = items.finished_after(params[:finished_after]) if params[:finished_after].present?
104
105    items
106  end
107
108  def by_environment(items)
109    if params[:project].present? && params[:environment].present?
110      items.for_environment_name(params[:project], params[:environment])
111    else
112      items
113    end
114  end
115
116  def by_status(items)
117    return items unless params[:status].present?
118
119    unless Deployment.statuses.key?(params[:status])
120      raise ArgumentError, "The deployment status #{params[:status]} is invalid"
121    end
122
123    items.for_status(params[:status])
124  end
125
126  def build_sort_params
127    order_by = ALLOWED_SORT_VALUES.include?(params[:order_by]) ? params[:order_by] : DEFAULT_SORT_VALUE
128    order_direction = ALLOWED_SORT_DIRECTIONS.include?(params[:sort]) ? params[:sort] : DEFAULT_SORT_DIRECTION
129
130    { order_by => order_direction }
131  end
132
133  def optimize_sort_params!(sort_params)
134    sort_direction = sort_params.each_value.first
135
136    # Implicitly enforce the ordering when filtered by `updated_at` column for performance optimization.
137    # See https://gitlab.com/gitlab-org/gitlab/-/issues/325627#note_552417509.
138    # We remove this in https://gitlab.com/gitlab-org/gitlab/-/issues/328500.
139    if filter_by_updated_at?
140      sort_params.replace('updated_at' => sort_direction)
141    end
142
143    if sort_params['created_at'] || sort_params['iid']
144      # Sorting by `id` produces the same result as sorting by `created_at` or `iid`
145      sort_params.replace(id: sort_direction)
146    elsif sort_params['updated_at']
147      # This adds the order as a tie-breaker when multiple rows have the same updated_at value.
148      # See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/20848.
149      sort_params.merge!(id: sort_direction)
150    end
151  end
152
153  def filter_by_updated_at?
154    params[:updated_before].present? || params[:updated_after].present?
155  end
156
157  def filter_by_finished_at?
158    params[:finished_before].present? || params[:finished_after].present?
159  end
160
161  def filter_by_successful_deployment?
162    params[:status].to_s == 'success'
163  end
164
165  def order_by_updated_at?
166    params[:order_by].to_s == 'updated_at'
167  end
168
169  def order_by_finished_at?
170    params[:order_by].to_s == 'finished_at'
171  end
172
173  # rubocop: disable CodeReuse/ActiveRecord
174  def preload_associations(scope)
175    scope.includes(
176      :user,
177      environment: [],
178      deployable: {
179        job_artifacts: [],
180        pipeline: {
181          project: {
182            route: [],
183            namespace: :route
184          }
185        },
186        project: {
187          namespace: :route
188        }
189      }
190    )
191  end
192  # rubocop: enable CodeReuse/ActiveRecord
193end
194