blob: 558044cdc4820f3abd9b855286555b956d4fba82 [file] [log] [blame]
Copybara854996b2021-09-07 19:36:02 +00001# Copyright 2016 The Chromium Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style
3# license that can be found in the LICENSE file or at
4# https://developers.google.com/open-source/licenses/bsd
5
6"""Helper functions for sorting lists of project artifacts.
7
8This module exports the SortArtifacts function that does sorting of
9Monorail business objects (e.g., an issue). The sorting is done by
10extracting relevant values from the PB using a dictionary of
11accessor functions.
12
13The desired sorting directives are specified in part of the user's
14HTTP request. This sort spec consists of the names of the columns
15with optional minus signs to indicate descending sort order.
16
17The tool configuration object also affects sorting. When sorting by
18key-value labels, the well-known labels are considered to come
19before any non-well-known labels, and those well-known labels sort in
20the order in which they are defined in the tool config PB.
21"""
22from __future__ import print_function
23from __future__ import division
24from __future__ import absolute_import
25
26from functools import total_ordering
27
28import settings
29from proto import tracker_pb2
30from services import caches
31from tracker import tracker_bizobj
32from tracker import tracker_constants
33
34
35@total_ordering
36class DescendingValue(object):
37 """A wrapper which reverses the sort order of values."""
38
39 @classmethod
40 def MakeDescendingValue(cls, obj):
41 """Make a value that sorts in the reverse order as obj."""
42 if isinstance(obj, int):
43 return -obj
44 if obj == MAX_STRING:
45 return MIN_STRING
46 if obj == MIN_STRING:
47 return MAX_STRING
48 if isinstance(obj, list):
49 return [cls.MakeDescendingValue(item) for item in reversed(obj)]
50 return DescendingValue(obj)
51
52 def __init__(self, val):
53 self.val = val
54
55 def __eq__(self, other):
56 if isinstance(other, DescendingValue):
57 return self.val == other.val
58 return self.val == other
59
60 def __ne__(self, other):
61 if isinstance(other, DescendingValue):
62 return self.val != other.val
63 return self.val != other
64
65 def __lt__(self, other):
66 if isinstance(other, DescendingValue):
67 return other.val < self.val
68 return other < self.val
69
70 def __repr__(self):
71 return 'DescendingValue(%r)' % self.val
72
73
74# A string that sorts after every other string, and one that sorts before them.
75MAX_STRING = '~~~'
76MIN_STRING = DescendingValue(MAX_STRING)
77
78
79# RAMCache {issue_id: {column_name: sort_key, ...}, ...}
80art_values_cache = None
81
82
83def InitializeArtValues(services):
84 global art_values_cache
85 art_values_cache = caches.RamCache(
86 services.cache_manager, 'issue', max_size=settings.issue_cache_max_size)
87
88
89def InvalidateArtValuesKeys(cnxn, keys):
90 art_values_cache.InvalidateKeys(cnxn, keys)
91
92
93def SortArtifacts(
94 artifacts, config, accessors, postprocessors, group_by_spec, sort_spec,
95 users_by_id=None, tie_breakers=None):
96 """Return a list of artifacts sorted by the user's sort specification.
97
98 In the following, an "accessor" is a function(art) -> [field_value, ...].
99
100 Args:
101 artifacts: an unsorted list of project artifact PBs.
102 config: Project config PB instance that defines the sort order for
103 labels and statuses in this project.
104 accessors: dict {column_name: accessor} to get values from the artifacts.
105 postprocessors: dict {column_name: postprocessor} to get user emails
106 and timestamps.
107 group_by_spec: string that lists the grouping order
108 sort_spec: string that lists the sort order
109 users_by_id: optional dictionary {user_id: user_view,...} for all users
110 who participate in the list of artifacts.
111 tie_breakers: list of column names to add to the end of the sort
112 spec if they are not already somewhere in the sort spec.
113
114 Returns:
115 A sorted list of artifacts.
116
117 Note: if username_cols is supplied, then users_by_id should be too.
118
119 The approach to sorting is to construct a comprehensive sort key for
120 each artifact. To create the sort key, we (a) build lists with a
121 variable number of fields to sort on, and (b) allow individual
122 fields to be sorted in descending order. Even with the time taken
123 to build the sort keys, calling sorted() with the key seems to be
124 faster overall than doing multiple stable-sorts or doing one sort
125 using a multi-field comparison function.
126 """
127 sort_directives = ComputeSortDirectives(
128 config, group_by_spec, sort_spec, tie_breakers=tie_breakers)
129
130 # Build a list of accessors that will extract sort keys from the issues.
131 accessor_pairs = [
132 (sd, _MakeCombinedSortKeyAccessor(
133 sd, config, accessors, postprocessors, users_by_id))
134 for sd in sort_directives]
135
136 def SortKey(art):
137 """Make a sort_key for the given artifact, used by sorted() below."""
138 if art_values_cache.HasItem(art.issue_id):
139 art_values = art_values_cache.GetItem(art.issue_id)
140 else:
141 art_values = {}
142
143 sort_key = []
144 for sd, accessor in accessor_pairs:
145 if sd not in art_values:
146 art_values[sd] = accessor(art)
147 sort_key.append(art_values[sd])
148
149 art_values_cache.CacheItem(art.issue_id, art_values)
150 return sort_key
151
152 return sorted(artifacts, key=SortKey)
153
154
155def ComputeSortDirectives(config, group_by_spec, sort_spec, tie_breakers=None):
156 """Return a list with sort directives to be used in sorting.
157
158 Args:
159 config: Project config PB instance that defines the sort order for
160 labels and statuses in this project.
161 group_by_spec: string that lists the grouping order
162 sort_spec: string that lists the sort order
163 tie_breakers: list of column names to add to the end of the sort
164 spec if they are not already somewhere in the sort spec.
165
166 Returns:
167 A list of lower-case column names, each one may have a leading
168 minus-sign.
169 """
170 # Prepend the end-user's sort spec to any project default sort spec.
171 if tie_breakers is None:
172 tie_breakers = ['id']
173 sort_spec = '%s %s %s' % (
174 group_by_spec, sort_spec, config.default_sort_spec)
175 # Sort specs can have interfering sort orders, so remove any duplicates.
176 field_names = set()
177 sort_directives = []
178 for sort_directive in sort_spec.lower().split():
179 field_name = sort_directive.lstrip('-')
180 if field_name not in field_names:
181 sort_directives.append(sort_directive)
182 field_names.add(field_name)
183
184 # Add in the project name so that the overall ordering is completely
185 # defined in cross-project search. Otherwise, issues jump up and
186 # down on each reload of the same query, and prev/next links get
187 # messed up. It's a no-op in single projects.
188 if 'project' not in sort_directives:
189 sort_directives.append('project')
190
191 for tie_breaker in tie_breakers:
192 if tie_breaker not in sort_directives:
193 sort_directives.append(tie_breaker)
194
195 return sort_directives
196
197
198def _MakeCombinedSortKeyAccessor(
199 sort_directive, config, accessors, postprocessors, users_by_id):
200 """Return an accessor that extracts a sort key for a UI table column.
201
202 Args:
203 sort_directive: string with column name and optional leading minus sign,
204 for combined columns, it may have slashes, e.g., "-priority/pri".
205 config: ProjectIssueConfig instance that defines the sort order for
206 labels and statuses in this project.
207 accessors: dictionary of (column_name -> accessor) to get values
208 from the artifacts.
209 postprocessors: dict {column_name: postprocessor} to get user emails
210 and timestamps.
211 users_by_id: dictionary {user_id: user_view,...} for all users
212 who participate in the list of artifacts (e.g., owners, reporters, cc).
213
214 Returns:
215 A list of accessor functions that can be applied to an issue to extract
216 the relevant sort key value.
217
218 The strings for status and labels are converted to lower case in
219 this method so that they sort like case-insensitive enumerations.
220 Any component-specific field of the artifact is sorted according to the
221 value returned by the accessors defined in that component. Those
222 accessor functions should lower case string values for fields where
223 case-insensitive sorting is desired.
224 """
225 if sort_directive.startswith('-'):
226 combined_col_name = sort_directive[1:]
227 descending = True
228 else:
229 combined_col_name = sort_directive
230 descending = False
231
232 wk_labels = [wkl.label for wkl in config.well_known_labels]
233 accessors = [
234 _MakeSingleSortKeyAccessor(
235 col_name, config, accessors, postprocessors, users_by_id, wk_labels)
236 for col_name in combined_col_name.split('/')]
237
238 # The most common case is that we sort on a single column, like "priority".
239 if len(accessors) == 1:
240 return _MaybeMakeDescending(accessors[0], descending)
241
242 # Less commonly, we are sorting on a combined column like "priority/pri".
243 def CombinedAccessor(art):
244 """Flatten and sort the values for each column in a combined column."""
245 key_part = []
246 for single_accessor in accessors:
247 value = single_accessor(art)
248 if isinstance(value, list):
249 key_part.extend(value)
250 else:
251 key_part.append(value)
252 return sorted(key_part)
253
254 return _MaybeMakeDescending(CombinedAccessor, descending)
255
256
257def _MaybeMakeDescending(accessor, descending):
258 """If descending is True, return a new function that reverses accessor."""
259 if not descending:
260 return accessor
261
262 def DescendingAccessor(art):
263 asc_value = accessor(art)
264 return DescendingValue.MakeDescendingValue(asc_value)
265
266 return DescendingAccessor
267
268
269def _MakeSingleSortKeyAccessor(
270 col_name, config, accessors, postprocessors, users_by_id, wk_labels):
271 """Return an accessor function for a single simple UI column."""
272 # Case 1. Handle built-in fields: status, component.
273 if col_name == 'status':
274 wk_statuses = [wks.status for wks in config.well_known_statuses]
275 return _IndexOrLexical(wk_statuses, accessors[col_name])
276
277 if col_name == 'component':
278 comp_defs = sorted(config.component_defs, key=lambda cd: cd.path.lower())
279 comp_ids = [cd.component_id for cd in comp_defs]
280 return _IndexListAccessor(comp_ids, accessors[col_name])
281
282 # Case 2. Any other defined accessor functions.
283 if col_name in accessors:
284 if postprocessors and col_name in postprocessors:
285 # sort users by email address or timestamp rather than user ids.
286 return _MakeAccessorWithPostProcessor(
287 users_by_id, accessors[col_name], postprocessors[col_name])
288 else:
289 return accessors[col_name]
290
291 # Case 3. Anything else is assumed to be a label prefix or custom field.
292 return _IndexOrLexicalList(
293 wk_labels, config.field_defs, col_name, users_by_id)
294
295
296IGNORABLE_INDICATOR = -1
297
298
299def _PrecomputeSortIndexes(values, col_name):
300 """Precompute indexes of strings in the values list for fast lookup later."""
301 # Make a dictionary that immediately gives us the index of any value
302 # in the list, and also add the same values in all-lower letters. In
303 # the case where two values differ only by case, the later value wins,
304 # which is fine.
305 indexes = {}
306 if col_name:
307 prefix = col_name + '-'
308 else:
309 prefix = ''
310 for idx, val in enumerate(values):
311 if val.lower().startswith(prefix):
312 indexes[val] = idx
313 indexes[val.lower()] = idx
314 else:
315 indexes[val] = IGNORABLE_INDICATOR
316 indexes[val.lower()] = IGNORABLE_INDICATOR
317
318 return indexes
319
320
321def _MakeAccessorWithPostProcessor(users_by_id, base_accessor, postprocessor):
322 """Make an accessor that returns a list of user_view properties for sorting.
323
324 Args:
325 users_by_id: dictionary {user_id: user_view, ...} for all participants
326 in the entire list of artifacts.
327 base_accessor: an accessor function f(artifact) -> user_id.
328 postprocessor: function f(user_view) -> single sortable value.
329
330 Returns:
331 An accessor f(artifact) -> value that can be used in sorting
332 the decorated list.
333 """
334
335 def Accessor(art):
336 """Return a user edit name for the given artifact's base_accessor."""
337 id_or_id_list = base_accessor(art)
338 if isinstance(id_or_id_list, list):
339 values = [postprocessor(users_by_id[user_id])
340 for user_id in id_or_id_list]
341 else:
342 values = [postprocessor(users_by_id[id_or_id_list])]
343
344 return sorted(values) or MAX_STRING
345
346 return Accessor
347
348
349def _MakeColumnAccessor(col_name):
350 """Make an accessor for an issue's labels that have col_name as a prefix.
351
352 Args:
353 col_name: string column name.
354
355 Returns:
356 An accessor that can be applied to an artifact to return a list of
357 labels that have col_name as a prefix.
358
359 For example, _MakeColumnAccessor('priority')(issue) could result in
360 [], or ['priority-high'], or a longer list for multi-valued labels.
361 """
362 prefix = col_name + '-'
363
364 def Accessor(art):
365 """Return a list of label values on the given artifact."""
366 result = [label.lower() for label in tracker_bizobj.GetLabels(art)
367 if label.lower().startswith(prefix)]
368 return result
369
370 return Accessor
371
372
373def _IndexOrLexical(wk_values, base_accessor):
374 """Return an accessor to score an artifact based on a user-defined ordering.
375
376 Args:
377 wk_values: a list of well-known status values from the config.
378 base_accessor: function that gets a field from a given issue.
379
380 Returns:
381 An accessor that can be applied to an issue to return a suitable
382 sort key.
383
384 For example, when used to sort issue statuses, these accessors return an
385 integer for well-known statuses, a string for odd-ball statuses, and an
386 extreme value key for issues with no status. That causes issues to appear
387 in the expected order with odd-ball issues sorted lexicographically after
388 the ones with well-known status values, and issues with no defined status at
389 the very end.
390 """
391 well_known_value_indexes = _PrecomputeSortIndexes(wk_values, '')
392
393 def Accessor(art):
394 """Custom-made function to return a specific value of any issue."""
395 value = base_accessor(art)
396 if not value:
397 # Undefined values sort last.
398 return MAX_STRING
399
400 try:
401 # Well-known values sort by index. Ascending sorting has positive ints
402 # in well_known_value_indexes.
403 return well_known_value_indexes[value]
404 except KeyError:
405 # Odd-ball values after well-known and lexicographically.
406 return value.lower()
407
408 return Accessor
409
410
411def _IndexListAccessor(wk_values, base_accessor):
412 """Return an accessor to score an artifact based on a user-defined ordering.
413
414 Args:
415 wk_values: a list of well-known values from the config.
416 base_accessor: function that gets a field from a given issue.
417
418 Returns:
419 An accessor that can be applied to an issue to return a suitable
420 sort key.
421 """
422 well_known_value_indexes = {
423 val: idx for idx, val in enumerate(wk_values)}
424
425 def Accessor(art):
426 """Custom-made function to return a specific value of any issue."""
427 values = base_accessor(art)
428 if not values:
429 # Undefined values sort last.
430 return MAX_STRING
431
432 indexes = [well_known_value_indexes.get(val, MAX_STRING) for val in values]
433 return sorted(indexes)
434
435 return Accessor
436
437
438def _IndexOrLexicalList(wk_values, full_fd_list, col_name, users_by_id):
439 """Return an accessor to score an artifact based on a user-defined ordering.
440
441 Args:
442 wk_values: A list of well-known labels from the config.
443 full_fd_list: list of FieldDef PBs that belong to the config.
444 col_name: lowercase string name of the column that will be sorted on.
445 users_by_id: A dictionary {user_id: user_view}.
446
447 Returns:
448 An accessor that can be applied to an issue to return a suitable
449 sort key.
450 """
451 well_known_value_indexes = _PrecomputeSortIndexes(wk_values, col_name)
452
453 if col_name.endswith(tracker_constants.APPROVER_COL_SUFFIX):
454 # Custom field names cannot end with the APPROVER_COL_SUFFIX. So the only
455 # possible relevant values are approvers for an APPROVAL_TYPE named
456 # field_name and any values from labels with the key 'field_name-approvers'.
457 field_name = col_name[:-len(tracker_constants.APPROVER_COL_SUFFIX)]
458 approval_fds = [fd for fd in full_fd_list
459 if (fd.field_name.lower() == field_name and
460 fd.field_type == tracker_pb2.FieldTypes.APPROVAL_TYPE)]
461
462 def ApproverAccessor(art):
463 """Custom-made function to return a sort value or an issue's approvers."""
464 idx_or_lex_list = (
465 _SortableApprovalApproverValues(art, approval_fds, users_by_id) +
466 _SortableLabelValues(art, col_name, well_known_value_indexes))
467 if not idx_or_lex_list:
468 return MAX_STRING # issues with no value sort to the end of the list.
469 return sorted(idx_or_lex_list)
470
471 return ApproverAccessor
472
473 # Column name does not end with APPROVER_COL_SUFFIX, so relevant values
474 # are Approval statuses or Field Values for fields named col_name and
475 # values from labels with the key equal to col_name.
476 field_name = col_name
477 phase_name = None
478 if '.' in col_name:
479 phase_name, field_name = col_name.split('.', 1)
480
481 fd_list = [fd for fd in full_fd_list
482 if (fd.field_name.lower() == field_name and
483 fd.field_type != tracker_pb2.FieldTypes.ENUM_TYPE and
484 bool(phase_name) == fd.is_phase_field)]
485 approval_fds = []
486 if not phase_name:
487 approval_fds = [fd for fd in fd_list if
488 fd.field_type == tracker_pb2.FieldTypes.APPROVAL_TYPE]
489
490 def Accessor(art):
491 """Custom-made function to return a sort value for any issue."""
492 idx_or_lex_list = (
493 _SortableApprovalStatusValues(art, approval_fds) +
494 _SortableFieldValues(art, fd_list, users_by_id, phase_name) +
495 _SortableLabelValues(art, col_name, well_known_value_indexes))
496 if not idx_or_lex_list:
497 return MAX_STRING # issues with no value sort to the end of the list.
498 return sorted(idx_or_lex_list)
499
500 return Accessor
501
502
503def _SortableApprovalStatusValues(art, fd_list):
504 """Return a list of approval statuses relevant to one UI table column."""
505 sortable_value_list = []
506 for fd in fd_list:
507 for av in art.approval_values:
508 if av.approval_id == fd.field_id:
509 # Order approval statuses by life cycle.
510 # NOT_SET == 8 but should be before all other statuses.
511 sortable_value_list.append(
512 0 if av.status.number == 8 else av.status.number)
513
514 return sortable_value_list
515
516
517def _SortableApprovalApproverValues(art, fd_list, users_by_id):
518 """Return a list of approval approvers relevant to one UI table column."""
519 sortable_value_list = []
520 for fd in fd_list:
521 for av in art.approval_values:
522 if av.approval_id == fd.field_id:
523 sortable_value_list.extend(
524 [users_by_id.get(approver_id).email
525 for approver_id in av.approver_ids
526 if users_by_id.get(approver_id)])
527
528 return sortable_value_list
529
530
531def _SortableFieldValues(art, fd_list, users_by_id, phase_name):
532 """Return a list of field values relevant to one UI table column."""
533 phase_id = None
534 if phase_name:
535 phase_id = next((
536 phase.phase_id for phase in art.phases
537 if phase.name.lower() == phase_name), None)
538 sortable_value_list = []
539 for fd in fd_list:
540 for fv in art.field_values:
541 if fv.field_id == fd.field_id and fv.phase_id == phase_id:
542 sortable_value_list.append(
543 tracker_bizobj.GetFieldValue(fv, users_by_id))
544
545 return sortable_value_list
546
547
548def _SortableLabelValues(art, col_name, well_known_value_indexes):
549 """Return a list of ints and strings for labels relevant to one UI column."""
550 col_name_dash = col_name + '-'
551 sortable_value_list = []
552 for label in tracker_bizobj.GetLabels(art):
553 idx_or_lex = well_known_value_indexes.get(label)
554 if idx_or_lex == IGNORABLE_INDICATOR:
555 continue # Label is known to not have the desired prefix.
556 if idx_or_lex is None:
557 if '-' not in label:
558 # Skip an irrelevant OneWord label and remember to ignore it later.
559 well_known_value_indexes[label] = IGNORABLE_INDICATOR
560 continue
561 label_lower = label.lower()
562 if label_lower.startswith(col_name_dash):
563 # Label is a key-value label with an odd-ball value, remember it
564 value = label_lower[len(col_name_dash):]
565 idx_or_lex = value
566 well_known_value_indexes[label] = value
567 else:
568 # Label was a key-value label that is not relevant to this column.
569 # Remember to ignore it later.
570 well_known_value_indexes[label] = IGNORABLE_INDICATOR
571 continue
572
573 sortable_value_list.append(idx_or_lex)
574
575 return sortable_value_list