blob: 8abb798b0b2285b7e26e94a616709f15bfdd45c3 [file] [log] [blame]
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +01001# Copyright 2016 The Chromium Authors
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
Copybara854996b2021-09-07 19:36:02 +00004
5"""Convert a user's issue sorting directives into SQL clauses.
6
7Some sort directives translate into simple ORDER BY column specifications.
8Other sort directives require that a LEFT JOIN be done to bring in
9relevant information that is then used in the ORDER BY.
10
11Sorting based on strings can slow down the DB because long sort-keys
12must be loaded into RAM, which means that fewer sort-keys fit into the
13DB's sorting buffers at a time. Also, Monorail defines the sorting
14order of well-known labels and statuses based on the order in which
15they are defined in the project's config. So, we determine the sort order of
16labels and status values before executing the query and then use the MySQL
17FIELD() function to sort their IDs in the desired order, without sorting
18strings.
19
20For more info, see the "Sorting in Monorail" and "What makes Monorail Fast?"
21design docs.
22"""
23from __future__ import print_function
24from __future__ import division
25from __future__ import absolute_import
26
27import logging
28
29from framework import sql
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +010030from mrproto import tracker_pb2
Copybara854996b2021-09-07 19:36:02 +000031from tracker import tracker_constants
32
33
34NATIVE_SORTABLE_FIELDS = [
35 'id', 'stars', 'attachments', 'opened', 'closed', 'modified',
36 'ownermodified', 'statusmodified', 'componentmodified',
37 ]
38
39FIELDS_TO_COLUMNS = {
40 'id': 'local_id',
41 'stars': 'star_count',
42 'attachments': 'attachment_count',
43 'ownermodified': 'owner_modified',
44 'statusmodified': 'status_modified',
45 'componentmodified': 'component_modified',
46 }
47
48APPROVAL_STATUS_SORT_ORDER = [
49 '\'not_set\'', '\'needs_review\'', '\'na\'', '\'review_requested\'',
50 '\'review_started\'', '\'need_info\'', '\'approved\'', '\'not_approved\'']
51
52
53def BuildSortClauses(
54 sort_directives, harmonized_labels, harmonized_statuses,
55 harmonized_fields):
56 """Return LEFT JOIN and ORDER BY clauses needed to sort the results."""
57 if not sort_directives:
58 return [], []
59
60 all_left_joins = []
61 all_order_by = []
62 for i, sd in enumerate(sort_directives):
63 left_join_parts, order_by_parts = _OneSortDirective(
64 i, sd, harmonized_labels, harmonized_statuses, harmonized_fields)
65 all_left_joins.extend(left_join_parts)
66 all_order_by.extend(order_by_parts)
67
68 return all_left_joins, all_order_by
69
70
71def _ProcessProjectSD(fmt):
72 """Convert a 'project' sort directive into SQL."""
73 left_joins = []
74 order_by = [(fmt('Issue.project_id {sort_dir}'), [])]
75 return left_joins, order_by
76
77
78def _ProcessReporterSD(fmt):
79 """Convert a 'reporter' sort directive into SQL."""
80 left_joins = [
81 (fmt('User AS {alias} ON Issue.reporter_id = {alias}.user_id'), [])]
82 order_by = [
83 (fmt('ISNULL({alias}.email) {sort_dir}'), []),
84 (fmt('{alias}.email {sort_dir}'), [])]
85 return left_joins, order_by
86
87
88def _ProcessOwnerSD(fmt):
89 """Convert a 'owner' sort directive into SQL."""
90 left_joins = [
91 (fmt('User AS {alias}_exp ON Issue.owner_id = {alias}_exp.user_id'), []),
92 (fmt('User AS {alias}_der ON '
93 'Issue.derived_owner_id = {alias}_der.user_id'), [])]
94 order_by = [
95 (fmt('(ISNULL({alias}_exp.email) AND ISNULL({alias}_der.email)) '
96 '{sort_dir}'), []),
97 (fmt('CONCAT({alias}_exp.email, {alias}_der.email) {sort_dir}'), [])]
98 return left_joins, order_by
99
100
101def _ProcessCcSD(fmt):
102 """Convert a 'cc' sort directive into SQL."""
103 # Note: derived cc's are included automatically.
104 # Note: This sorts on the best Cc, not all Cc addresses.
105 # Being more exact might require GROUP BY and GROUP_CONCAT().
106 left_joins = [
107 (fmt('Issue2Cc AS {alias} ON Issue.id = {alias}.issue_id '
108 'LEFT JOIN User AS {alias}_user '
109 'ON {alias}.cc_id = {alias}_user.user_id'), [])]
110 order_by = [
111 (fmt('ISNULL({alias}_user.email) {sort_dir}'), []),
112 (fmt('{alias}_user.email {sort_dir}'), [])]
113 return left_joins, order_by
114
115
116def _ProcessComponentSD(fmt):
117 """Convert a 'component' sort directive into SQL."""
118 # Note: derived components are included automatically.
119 # Note: This sorts on the best component, not all of them.
120 # Being more exact might require GROUP BY and GROUP_CONCAT().
121 left_joins = [
122 (fmt('Issue2Component AS {alias} ON Issue.id = {alias}.issue_id '
123 'LEFT JOIN ComponentDef AS {alias}_component '
124 'ON {alias}.component_id = {alias}_component.id'), [])]
125 order_by = [
126 (fmt('ISNULL({alias}_component.path) {sort_dir}'), []),
127 (fmt('{alias}_component.path {sort_dir}'), [])]
128 return left_joins, order_by
129
130
131def _ProcessSummarySD(fmt):
132 """Convert a 'summary' sort directive into SQL."""
133 left_joins = [
134 (fmt('IssueSummary AS {alias} ON Issue.id = {alias}.issue_id'), [])]
135 order_by = [(fmt('{alias}.summary {sort_dir}'), [])]
136 return left_joins, order_by
137
138
139def _ProcessStatusSD(fmt, harmonized_statuses):
140 """Convert a 'status' sort directive into SQL."""
141 left_joins = []
142 # Note: status_def_rows are already ordered by REVERSED rank.
143 wk_status_ids = [
144 stat_id for stat_id, rank, _ in harmonized_statuses
145 if rank is not None]
146 odd_status_ids = [
147 stat_id for stat_id, rank, _ in harmonized_statuses
148 if rank is None]
149 wk_status_ph = sql.PlaceHolders(wk_status_ids)
150 # Even though oddball statuses sort lexographically, use FIELD to determine
151 # the order so that the database sorts ints rather than strings for speed.
152 odd_status_ph = sql.PlaceHolders(odd_status_ids)
153
154 order_by = [] # appended to below: both well-known and oddball can apply
155 sort_col = ('IF(ISNULL(Issue.status_id), Issue.derived_status_id, '
156 'Issue.status_id)')
157 # Reverse sort by using rev_sort_dir because we want NULLs at the end.
158 if wk_status_ids:
159 order_by.append(
160 (fmt('FIELD({sort_col}, {wk_status_ph}) {rev_sort_dir}',
161 sort_col=sort_col, wk_status_ph=wk_status_ph),
162 wk_status_ids))
163 if odd_status_ids:
164 order_by.append(
165 (fmt('FIELD({sort_col}, {odd_status_ph}) {rev_sort_dir}',
166 sort_col=sort_col, odd_status_ph=odd_status_ph),
167 odd_status_ids))
168
169 return left_joins, order_by
170
171
172def _ProcessBlockedSD(fmt):
173 """Convert a 'blocked' sort directive into SQL."""
174 left_joins = [
175 (fmt('IssueRelation AS {alias} ON Issue.id = {alias}.issue_id '
176 'AND {alias}.kind = %s'),
177 ['blockedon'])]
178 order_by = [(fmt('ISNULL({alias}.dst_issue_id) {sort_dir}'), [])]
179 return left_joins, order_by
180
181
182def _ProcessBlockedOnSD(fmt):
183 """Convert a 'blockedon' sort directive into SQL."""
184 left_joins = [
185 (fmt('IssueRelation AS {alias} ON Issue.id = {alias}.issue_id '
186 'AND {alias}.kind = %s'),
187 ['blockedon'])]
188 order_by = [(fmt('ISNULL({alias}.dst_issue_id) {sort_dir}'), []),
189 (fmt('{alias}.dst_issue_id {sort_dir}'), [])]
190 return left_joins, order_by
191
192
193def _ProcessBlockingSD(fmt):
194 """Convert a 'blocking' sort directive into SQL."""
195 left_joins = [
196 (fmt('IssueRelation AS {alias} ON Issue.id = {alias}.dst_issue_id '
197 'AND {alias}.kind = %s'),
198 ['blockedon'])]
199 order_by = [(fmt('ISNULL({alias}.issue_id) {sort_dir}'), []),
200 (fmt('{alias}.issue_id {sort_dir}'), [])]
201 return left_joins, order_by
202
203
204def _ProcessMergedIntoSD(fmt):
205 """Convert a 'mergedinto' sort directive into SQL."""
206 left_joins = [
207 (fmt('IssueRelation AS {alias} ON Issue.id = {alias}.issue_id '
208 'AND {alias}.kind = %s'),
209 ['mergedinto'])]
210 order_by = [(fmt('ISNULL({alias}.dst_issue_id) {sort_dir}'), []),
211 (fmt('{alias}.dst_issue_id {sort_dir}'), [])]
212 return left_joins, order_by
213
214
215def _ProcessOwnerLastVisitSD(fmt):
216 """Convert a 'ownerlastvisit' sort directive into SQL."""
217 left_joins = [
218 (fmt('User AS {alias} ON (Issue.owner_id = {alias}.user_id OR '
219 'Issue.derived_owner_id = {alias}.user_id)'), [])]
220 order_by = [
221 (fmt('ISNULL({alias}.last_visit_timestamp) {sort_dir}'), []),
222 (fmt('{alias}.last_visit_timestamp {sort_dir}'), [])]
223 return left_joins, order_by
224
225
226def _ProcessCustomAndLabelSD(
227 sd, harmonized_labels, harmonized_fields, alias, sort_dir, fmt):
228 """Convert a label or custom field sort directive into SQL."""
229 left_joins = []
230 order_by = []
231 phase_name = None
232 # If a custom field is an approval_type with no suffix, the
233 # approvals should be sorted by status.
234 approval_suffix = '-status'
235 approval_fd_list = []
236
237 # Check for reserved suffixes in col_name sd.
238 # TODO(jojwang): check for other suffixes in
239 # tracker_constants.RESERVED_COL_NAME_SUFFIXES
240 if sd.endswith(tracker_constants.APPROVER_COL_SUFFIX):
241 field_name = sd[:-len(tracker_constants.APPROVER_COL_SUFFIX)]
242 fd_list = []
243 approval_fd_list = [fd for fd in harmonized_fields
244 if fd.field_name.lower() == field_name]
245 approval_suffix = tracker_constants.APPROVER_COL_SUFFIX
246 else:
247 field_name = sd
248 if '.' in sd:
249 phase_name, field_name = sd.split('.', 1)
250
251 fd_list = [fd for fd in harmonized_fields
252 if fd.field_name.lower() == field_name]
253 if not phase_name:
254 approval_fd_list = [fd for fd in fd_list if
255 fd.field_type == tracker_pb2.FieldTypes.APPROVAL_TYPE]
256
257 # 'alias' is used for all the CustomField, Approval, and Label sort clauses.
258 # Custom field aliases are alwyas appended by the value_col name.
259 # Approval aliases are always appended with 'approval'.
260 # Label clauses use 'alias' as-is.
261 if fd_list:
262 int_left_joins, int_order_by = _CustomFieldSortClauses(
263 fd_list, tracker_pb2.FieldTypes.INT_TYPE, 'int_value',
264 alias, sort_dir, phase_name=phase_name)
265 str_left_joins, str_order_by = _CustomFieldSortClauses(
266 fd_list, tracker_pb2.FieldTypes.STR_TYPE, 'str_value',
267 alias, sort_dir, phase_name=phase_name)
268 user_left_joins, user_order_by = _CustomFieldSortClauses(
269 fd_list, tracker_pb2.FieldTypes.USER_TYPE, 'user_id',
270 alias, sort_dir, phase_name=phase_name)
271 left_joins.extend(int_left_joins + str_left_joins + user_left_joins)
272 order_by.extend(int_order_by + str_order_by + user_order_by)
273
274 if approval_fd_list:
275 approval_left_joins, approval_order_by = _ApprovalFieldSortClauses(
276 approval_fd_list, approval_suffix, fmt)
277 left_joins.extend(approval_left_joins)
278 order_by.extend(approval_order_by)
279
280 label_left_joinss, label_order_by = _LabelSortClauses(
281 sd, harmonized_labels, fmt)
282 left_joins.extend(label_left_joinss)
283 order_by.extend(label_order_by)
284
285 return left_joins, order_by
286
287
288def _ApprovalFieldSortClauses(
289 approval_fd_list, approval_suffix, fmt):
290 """Give LEFT JOIN and ORDER BY terms for approval sort directives."""
291 approver_left_joins = None
292 if approval_suffix == tracker_constants.APPROVER_COL_SUFFIX:
293 tbl_name = 'IssueApproval2Approver'
294 approver_left_joins = (
295 fmt('User AS {alias}_approval_user '
296 'ON {alias}_approval.approver_id = {alias}_approval_user.user_id'),
297 [])
298 order_by = [
299 (fmt('ISNULL({alias}_approval_user.email) {sort_dir}'), []),
300 (fmt('{alias}_approval_user.email {sort_dir}'), [])]
301 else:
302 tbl_name = 'Issue2ApprovalValue'
303 order_by = [
304 (fmt('FIELD({alias}_approval.status, {approval_status_ph}) '
305 '{rev_sort_dir}',
306 approval_status_ph=sql.PlaceHolders(APPROVAL_STATUS_SORT_ORDER)),
307 APPROVAL_STATUS_SORT_ORDER
308 )]
309
310 left_joins = [(
311 fmt('{tbl_name} AS {alias}_approval '
312 'ON Issue.id = {alias}_approval.issue_id '
313 'AND {alias}_approval.approval_id IN ({approval_ids_ph})',
314 approval_ids_ph=sql.PlaceHolders(approval_fd_list),
315 tbl_name=tbl_name),
316 [fd.field_id for fd in approval_fd_list]
317 )]
318
319 if approver_left_joins:
320 left_joins.append(approver_left_joins)
321
322 return left_joins, order_by
323
324
325def _LabelSortClauses(sd, harmonized_labels, fmt):
326 """Give LEFT JOIN and ORDER BY terms for label sort directives."""
327 # Note: derived labels should work automatically.
328
329 # label_def_rows are already ordered by REVERSED rank.
330 wk_label_ids = [
331 label_id for label_id, rank, label in harmonized_labels
332 if label.lower().startswith('%s-' % sd) and rank is not None]
333 odd_label_ids = [
334 label_id for label_id, rank, label in harmonized_labels
335 if label.lower().startswith('%s-' % sd) and rank is None]
336 all_label_ids = wk_label_ids + odd_label_ids
337
338 if all_label_ids:
339 left_joins = [
340 (fmt('Issue2Label AS {alias} ON Issue.id = {alias}.issue_id '
341 'AND {alias}.label_id IN ({all_label_ph})',
342 all_label_ph=sql.PlaceHolders(all_label_ids)),
343 all_label_ids)]
344 else:
345 left_joins = []
346
347 order_by = []
348 # Reverse sort by using rev_sort_dir because we want NULLs at the end.
349 if wk_label_ids:
350 order_by.append(
351 (fmt('FIELD({alias}.label_id, {wk_label_ph}) {rev_sort_dir}',
352 wk_label_ph=sql.PlaceHolders(wk_label_ids)),
353 wk_label_ids))
354 if odd_label_ids:
355 # Even though oddball labels sort lexographically, use FIELD to determine
356 # the order so that the database sorts ints rather than strings for speed
357 order_by.append(
358 (fmt('FIELD({alias}.label_id, {odd_label_ph}) {rev_sort_dir}',
359 odd_label_ph=sql.PlaceHolders(odd_label_ids)),
360 odd_label_ids))
361
362 return left_joins, order_by
363
364
365def _CustomFieldSortClauses(
366 fd_list, value_type, value_column, alias, sort_dir, phase_name=None):
367 """Give LEFT JOIN and ORDER BY terms for custom fields of the given type."""
368 relevant_fd_list = [fd for fd in fd_list if fd.field_type == value_type]
369 if not relevant_fd_list:
370 return [], []
371
372 field_ids_ph = sql.PlaceHolders(relevant_fd_list)
373 def Fmt(sql_str):
374 return sql_str.format(
375 value_column=value_column, sort_dir=sort_dir,
376 field_ids_ph=field_ids_ph, alias=alias + '_' + value_column,
377 phase_name=phase_name)
378
379 left_joins = [
380 (Fmt('Issue2FieldValue AS {alias} ON Issue.id = {alias}.issue_id '
381 'AND {alias}.field_id IN ({field_ids_ph})'),
382 [fd.field_id for fd in relevant_fd_list])]
383
384 if phase_name:
385 left_joins.append(
386 (Fmt('IssuePhaseDef AS {alias}_phase '
387 'ON {alias}.phase_id = {alias}_phase.id '
388 'AND LOWER({alias}_phase.name) = LOWER(%s)'),
389 [phase_name]))
390
391 if value_type == tracker_pb2.FieldTypes.USER_TYPE:
392 left_joins.append(
393 (Fmt('User AS {alias}_user ON {alias}.user_id = {alias}_user.user_id'),
394 []))
395 order_by = [
396 (Fmt('ISNULL({alias}_user.email) {sort_dir}'), []),
397 (Fmt('{alias}_user.email {sort_dir}'), [])]
398 else:
399 # Unfortunately, this sorts on the best field value, not all of them.
400 order_by = [
401 (Fmt('ISNULL({alias}.{value_column}) {sort_dir}'), []),
402 (Fmt('{alias}.{value_column} {sort_dir}'), [])]
403
404 return left_joins, order_by
405
406
407_PROCESSORS = {
408 'component': _ProcessComponentSD,
409 'project': _ProcessProjectSD,
410 'reporter': _ProcessReporterSD,
411 'owner': _ProcessOwnerSD,
412 'cc': _ProcessCcSD,
413 'summary': _ProcessSummarySD,
414 'blocked': _ProcessBlockedSD,
415 'blockedon': _ProcessBlockedOnSD,
416 'blocking': _ProcessBlockingSD,
417 'mergedinto': _ProcessMergedIntoSD,
418 'ownerlastvisit': _ProcessOwnerLastVisitSD,
419 }
420
421
422def _OneSortDirective(
423 i, sd, harmonized_labels, harmonized_statuses, harmonized_fields):
424 """Return SQL clauses to do the sorting for one sort directive."""
425 alias = 'Sort%d' % i
426 if sd.startswith('-'):
427 sort_dir, rev_sort_dir = 'DESC', 'ASC'
428 sd = sd[1:]
429 else:
430 sort_dir, rev_sort_dir = 'ASC', 'DESC'
431
432 def Fmt(sql_str, **kwargs):
433 return sql_str.format(
434 sort_dir=sort_dir, rev_sort_dir=rev_sort_dir, alias=alias,
435 sd=sd, col=FIELDS_TO_COLUMNS.get(sd, sd), **kwargs)
436
437 if sd in NATIVE_SORTABLE_FIELDS:
438 left_joins = []
439 order_by = [(Fmt('Issue.{col} {sort_dir}'), [])]
440 return left_joins, order_by
441
442 elif sd in _PROCESSORS:
443 proc = _PROCESSORS[sd]
444 return proc(Fmt)
445
446 elif sd == 'status':
447 return _ProcessStatusSD(Fmt, harmonized_statuses)
448 else: # otherwise, it must be a field or label, or both
449 return _ProcessCustomAndLabelSD(
450 sd, harmonized_labels, harmonized_fields, alias, sort_dir, Fmt)