Index: appengine/monorail/framework/sorting.py |
diff --git a/appengine/monorail/framework/sorting.py b/appengine/monorail/framework/sorting.py |
new file mode 100644 |
index 0000000000000000000000000000000000000000..282935c9ba69df50c1c6d35b01354070bde337c9 |
--- /dev/null |
+++ b/appengine/monorail/framework/sorting.py |
@@ -0,0 +1,480 @@ |
+# Copyright 2016 The Chromium Authors. All rights reserved. |
+# Use of this source code is govered by a BSD-style |
+# license that can be found in the LICENSE file or at |
+# https://developers.google.com/open-source/licenses/bsd |
+ |
+"""Helper functions for sorting lists of project artifacts. |
+ |
+This module exports the SortArtifacts function that does sorting of |
+Monorail business objects (e.g., an issue). The sorting is done by |
+extracting relevant values from the PB using a dictionary of |
+accessor functions. |
+ |
+The desired sorting directives are specified in part of the user's |
+HTTP request. This sort spec consists of the names of the columns |
+with optional minus signs to indicate descending sort order. |
+ |
+The tool configuration object also affects sorting. When sorting by |
+key-value labels, the well-known labels are considered to come |
+before any non-well-known labels, and those well-known labels sort in |
+the order in which they are defined in the tool config PB. |
+""" |
+ |
+import logging |
+ |
+import settings |
+from framework import framework_constants |
+from proto import tracker_pb2 |
+from tracker import tracker_bizobj |
+ |
+ |
+class DescendingValue(object): |
+ """A wrapper which reverses the sort order of values.""" |
+ |
+ @classmethod |
+ def MakeDescendingValue(cls, obj): |
+ """Make a value that sorts in the reverse order as obj.""" |
+ if isinstance(obj, int): |
+ return -obj |
+ if obj == MAX_STRING: |
+ return MIN_STRING |
+ if obj == MIN_STRING: |
+ return MAX_STRING |
+ if isinstance(obj, list): |
+ return [cls.MakeDescendingValue(item) for item in reversed(obj)] |
+ return DescendingValue(obj) |
+ |
+ def __init__(self, val): |
+ self.val = val |
+ |
+ def __cmp__(self, other): |
+ """Return -1, 0, or 1 base on the reverse of the normal sort order.""" |
+ if isinstance(other, DescendingValue): |
+ return cmp(other.val, self.val) |
+ else: |
+ return cmp(other, self.val) |
+ |
+ def __repr__(self): |
+ return 'DescendingValue(%r)' % self.val |
+ |
+ |
+# A string that sorts after every other string, and one that sorts before them. |
+MAX_STRING = '~~~' |
+MIN_STRING = DescendingValue(MAX_STRING) |
+ |
+ |
+# RAMCache {issue_id: {column_name: sort_key, ...}, ...} |
+art_values_cache = None |
+ |
+ |
+def InitializeArtValues(services): |
+ global art_values_cache |
+ art_values_cache = services.cache_manager.MakeCache( |
+ 'issue', max_size=settings.issue_cache_max_size) |
+ |
+ |
+def SortArtifacts( |
+ mr, artifacts, config, accessors, username_cols=None, users_by_id=None): |
+ """Return a list of artifacts sorted by the user's sort specification. |
+ |
+ In the following, an "accessor" is a function(art) -> [field_value, ...]. |
+ |
+ Args: |
+ mr: commonly used info parsed from the request, including query. |
+ artifacts: an unsorted list of project artifact PBs. |
+ config: Project config PB instance that defines the sort order for |
+ labels and statuses in this project. |
+ accessors: dictionary of (column_name -> accessor) to get values |
+ from the artifacts. |
+ username_cols: optional list of lowercase column names that will show |
+ user names. |
+ users_by_id: optional dictionary {user_id: user_view,...} for all users |
+ who participate in the list of artifacts. |
+ |
+ Returns: |
+ A sorted list of artifacts. |
+ |
+ Note: if username_cols is supplied, then users_by_id should be too. |
+ |
+ The approach to sorting is to construct a comprehensive sort key for |
+ each artifact. To create the sort key, we (a) build lists with a |
+ variable number of fields to sort on, and (b) allow individual |
+ fields to be sorted in descending order. Even with the time taken |
+ to build the sort keys, calling sorted() with the key seems to be |
+ faster overall than doing multiple stable-sorts or doing one sort |
+ using a multi-field comparison function. |
+ """ |
+ sort_directives = ComputeSortDirectives(mr, config) |
+ |
+ # Build a list of accessors that will extract sort keys from the issues. |
+ accessor_pairs = [ |
+ (sd, _MakeCombinedSortKeyAccessor( |
+ sd, config, accessors, username_cols, users_by_id)) |
+ for sd in sort_directives] |
+ |
+ def SortKey(art): |
+ """Make a sort_key for the given artifact, used by sorted() below.""" |
+ if art_values_cache.HasItem(art.issue_id): |
+ art_values = art_values_cache.GetItem(art.issue_id) |
+ else: |
+ art_values = {} |
+ |
+ sort_key = [] |
+ for sd, accessor in accessor_pairs: |
+ if sd not in art_values: |
+ art_values[sd] = accessor(art) |
+ sort_key.append(art_values[sd]) |
+ |
+ art_values_cache.CacheItem(art.issue_id, art_values) |
+ return sort_key |
+ |
+ return sorted(artifacts, key=SortKey) |
+ |
+ |
+def ComputeSortDirectives(mr, config, tie_breaker='id'): |
+ """Return a list with sort directives to be used in sorting. |
+ |
+ Args: |
+ mr: commonly used info parsed from the request, including query. |
+ config: Project config PB instance that defines the sort order for |
+ labels and statuses in this project. |
+ tie_breaker: column name to add to the end of the sort spec if it is |
+ not already somewhere in the sort spec. |
+ |
+ Returns: |
+ A list of lower-case column names, each one may have a leading |
+ minus-sign. |
+ """ |
+ # Prepend the end-user's sort spec to any project default sort spec. |
+ sort_spec = '%s %s %s' % ( |
+ mr.group_by_spec, mr.sort_spec, config.default_sort_spec) |
+ # Sort specs can have interfering sort orders, so remove any duplicates. |
+ field_names = set() |
+ sort_directives = [] |
+ for sort_directive in sort_spec.lower().split(): |
+ field_name = sort_directive.lstrip('-') |
+ if field_name not in field_names: |
+ sort_directives.append(sort_directive) |
+ field_names.add(field_name) |
+ |
+ # Add in the project name so that the overall ordering is completely |
+ # defined in cross-project search. Otherwise, issues jump up and |
+ # down on each reload of the same query, and prev/next links get |
+ # messed up. It's a no-op in single projects. |
+ if 'project' not in sort_directives: |
+ sort_directives.append('project') |
+ |
+ if tie_breaker not in sort_directives: |
+ sort_directives.append(tie_breaker) |
+ |
+ return sort_directives |
+ |
+ |
+def _MakeCombinedSortKeyAccessor( |
+ sort_directive, config, accessors, username_cols, users_by_id): |
+ """Return an accessor that extracts a sort key for a UI table column. |
+ |
+ Args: |
+ sort_directive: string with column name and optional leading minus sign, |
+ for combined columns, it may have slashes, e.g., "-priority/pri". |
+ config: ProjectIssueConfig instance that defines the sort order for |
+ labels and statuses in this project. |
+ accessors: dictionary of (column_name -> accessor) to get values |
+ from the artifacts. |
+ username_cols: list of lowercase names of columns that contain user names. |
+ users_by_id: dictionary {user_id: user_view,...} for all users |
+ who participate in the list of artifacts (e.g., owners, reporters, cc). |
+ |
+ Returns: |
+ A list of accessor functions that can be applied to an issue to extract |
+ the relevant sort key value. |
+ |
+ The strings for status and labels are converted to lower case in |
+ this method so that they sort like case-insensitive enumerations. |
+ Any component-specific field of the artifact is sorted according to the |
+ value returned by the accessors defined in that component. Those |
+ accessor functions should lower case string values for fields where |
+ case-insensitive sorting is desired. |
+ """ |
+ if sort_directive.startswith('-'): |
+ combined_col_name = sort_directive[1:] |
+ descending = True |
+ else: |
+ combined_col_name = sort_directive |
+ descending = False |
+ |
+ wk_labels = [wkl.label for wkl in config.well_known_labels] |
+ accessors = [ |
+ _MakeSingleSortKeyAccessor( |
+ col_name, config, accessors, username_cols, users_by_id, wk_labels) |
+ for col_name in combined_col_name.split('/')] |
+ |
+ # The most common case is that we sort on a single column, like "priority". |
+ if len(accessors) == 1: |
+ return _MaybeMakeDescending(accessors[0], descending) |
+ |
+ # Less commonly, we are sorting on a combined column like "priority/pri". |
+ def CombinedAccessor(art): |
+ """Flatten and sort the values for each column in a combined column.""" |
+ key_part = [] |
+ for single_accessor in accessors: |
+ value = single_accessor(art) |
+ if isinstance(value, list): |
+ key_part.extend(value) |
+ else: |
+ key_part.append(value) |
+ return sorted(key_part) |
+ |
+ return _MaybeMakeDescending(CombinedAccessor, descending) |
+ |
+ |
+def _MaybeMakeDescending(accessor, descending): |
+ """If descending is True, return a new function that reverses accessor.""" |
+ if not descending: |
+ return accessor |
+ |
+ def DescendingAccessor(art): |
+ asc_value = accessor(art) |
+ return DescendingValue.MakeDescendingValue(asc_value) |
+ |
+ return DescendingAccessor |
+ |
+ |
+def _MakeSingleSortKeyAccessor( |
+ col_name, config, accessors, username_cols, users_by_id, wk_labels): |
+ """Return an accessor function for a single simple UI column.""" |
+ # Case 1. Handle built-in fields: status, component. |
+ if col_name == 'status': |
+ wk_statuses = [wks.status for wks in config.well_known_statuses] |
+ return _IndexOrLexical(wk_statuses, accessors[col_name]) |
+ |
+ if col_name == 'component': |
+ comp_defs = sorted(config.component_defs, key=lambda cd: cd.path.lower()) |
+ comp_ids = [cd.component_id for cd in comp_defs] |
+ return _IndexListAccessor(comp_ids, accessors[col_name]) |
+ |
+ # Case 2. Any other defined accessor functions. |
+ if col_name in accessors: |
+ if username_cols and col_name in username_cols: |
+ # sort users by email address rather than user ids. |
+ return _UserEditNameAccessor(users_by_id, accessors[col_name]) |
+ else: |
+ return accessors[col_name] |
+ |
+ # Case 3. Anything else is assumed to be a label prefix or custom field. |
+ # TODO(jrobbins): user-valued custom fields. Find them at top of loop. |
+ fd_list = [ |
+ fd for fd in config.field_defs |
+ if (fd.field_name.lower() == col_name and |
+ fd.field_type != tracker_pb2.FieldTypes.ENUM_TYPE)] |
+ return _IndexOrLexicalList( |
+ wk_labels, fd_list, col_name, users_by_id) |
+ |
+ |
+IGNORABLE_INDICATOR = -1 |
+ |
+ |
+def _PrecomputeSortIndexes(values, col_name): |
+ """Precompute indexes of strings in the values list for fast lookup later.""" |
+ # Make a dictionary that immediately gives us the index of any value |
+ # in the list, and also add the same values in all-lower letters. In |
+ # the case where two values differ only by case, the later value wins, |
+ # which is fine. |
+ indexes = {} |
+ if col_name: |
+ prefix = col_name + '-' |
+ else: |
+ prefix = '' |
+ for idx, val in enumerate(values): |
+ if val.lower().startswith(prefix): |
+ indexes[val] = idx |
+ indexes[val.lower()] = idx |
+ else: |
+ indexes[val] = IGNORABLE_INDICATOR |
+ indexes[val.lower()] = IGNORABLE_INDICATOR |
+ |
+ return indexes |
+ |
+ |
+def _UserEditNameAccessor(users_by_id, base_accessor): |
+ """Make an accessor that returns a list of user edit names for sorting. |
+ |
+ Args: |
+ users_by_id: dictionary {user_id: user_view, ...} for all participants |
+ in the entire list of artifacts. |
+ base_accessor: an accessor function f(artifact) -> user_id. |
+ |
+ Returns: |
+ An accessor f(artifact) -> value that can be used in sorting |
+ the decorated list. |
+ """ |
+ |
+ def Accessor(art): |
+ """Return a user edit name for the given artifact's base_accessor.""" |
+ id_or_id_list = base_accessor(art) |
+ if isinstance(id_or_id_list, list): |
+ emails = [users_by_id[user_id].email |
+ for user_id in id_or_id_list] |
+ else: |
+ emails = [users_by_id[id_or_id_list].email] |
+ |
+ return sorted(emails) or MAX_STRING |
+ |
+ return Accessor |
+ |
+ |
+def _MakeColumnAccessor(col_name): |
+ """Make an accessor for an issue's labels that have col_name as a prefix. |
+ |
+ Args: |
+ col_name: string column name. |
+ |
+ Returns: |
+ An accessor that can be applied to an artifact to return a list of |
+ labels that have col_name as a prefix. |
+ |
+ For example, _MakeColumnAccessor('priority')(issue) could result in |
+ [], or ['priority-high'], or a longer list for multi-valued labels. |
+ """ |
+ prefix = col_name + '-' |
+ |
+ def Accessor(art): |
+ """Return a list of label values on the given artifact.""" |
+ result = [label.lower() for label in tracker_bizobj.GetLabels(art) |
+ if label.lower().startswith(prefix)] |
+ return result |
+ |
+ return Accessor |
+ |
+ |
+def _IndexOrLexical(wk_values, base_accessor): |
+ """Return an accessor to score an artifact based on a user-defined ordering. |
+ |
+ Args: |
+ wk_values: a list of well-known status values from the config. |
+ base_accessor: function that gets a field from a given issue. |
+ |
+ Returns: |
+ An accessor that can be applied to an issue to return a suitable |
+ sort key. |
+ |
+ For example, when used to sort issue statuses, these accessors return an |
+ integer for well-known statuses, a string for odd-ball statuses, and an |
+ extreme value key for issues with no status. That causes issues to appear |
+ in the expected order with odd-ball issues sorted lexicographically after |
+ the ones with well-known status values, and issues with no defined status at |
+ the very end. |
+ """ |
+ well_known_value_indexes = _PrecomputeSortIndexes(wk_values, '') |
+ |
+ def Accessor(art): |
+ """Custom-made function to return a specific value of any issue.""" |
+ value = base_accessor(art) |
+ if not value: |
+ # Undefined values sort last. |
+ return MAX_STRING |
+ |
+ try: |
+ # Well-known values sort by index. Ascending sorting has positive ints |
+ # in well_known_value_indexes. |
+ return well_known_value_indexes[value] |
+ except KeyError: |
+ # Odd-ball values after well-known and lexicographically. |
+ return value.lower() |
+ |
+ return Accessor |
+ |
+ |
+def _IndexListAccessor(wk_values, base_accessor): |
+ """Return an accessor to score an artifact based on a user-defined ordering. |
+ |
+ Args: |
+ wk_values: a list of well-known values from the config. |
+ base_accessor: function that gets a field from a given issue. |
+ |
+ Returns: |
+ An accessor that can be applied to an issue to return a suitable |
+ sort key. |
+ """ |
+ well_known_value_indexes = { |
+ val: idx for idx, val in enumerate(wk_values)} |
+ |
+ def Accessor(art): |
+ """Custom-made function to return a specific value of any issue.""" |
+ values = base_accessor(art) |
+ if not values: |
+ # Undefined values sort last. |
+ return MAX_STRING |
+ |
+ indexes = [well_known_value_indexes.get(val, MAX_STRING) for val in values] |
+ return sorted(indexes) |
+ |
+ return Accessor |
+ |
+ |
+def _IndexOrLexicalList(wk_values, fd_list, col_name, users_by_id): |
+ """Return an accessor to score an artifact based on a user-defined ordering. |
+ |
+ Args: |
+ wk_values: A list of well-known labels from the config. |
+ fd_list: list of FieldDef PBs that match the column name. These might not |
+ all have the same field_type. Enum-type field are not included. |
+ col_name: lowercase string name of the column that will be sorted on. |
+ users_by_id: A dictionary {user_id: user_view}. |
+ |
+ Returns: |
+ An accessor that can be applied to an issue to return a suitable |
+ sort key. |
+ """ |
+ well_known_value_indexes = _PrecomputeSortIndexes(wk_values, col_name) |
+ |
+ def Accessor(art): |
+ """Custom-made function to return a sort value for any issue.""" |
+ idx_or_lex_list = ( |
+ _SortableFieldValues(art, fd_list, users_by_id) + |
+ _SortableLabelValues(art, col_name, well_known_value_indexes)) |
+ if not idx_or_lex_list: |
+ return MAX_STRING # issues with no value sort to the end of the list. |
+ return sorted(idx_or_lex_list) |
+ |
+ return Accessor |
+ |
+ |
+def _SortableFieldValues(art, fd_list, users_by_id): |
+ """Return a list of field values relevant to one UI table column.""" |
+ sortable_value_list = [] |
+ for fd in fd_list: |
+ for fv in art.field_values: |
+ if fv.field_id == fd.field_id: |
+ sortable_value_list.append( |
+ tracker_bizobj.GetFieldValue(fv, users_by_id)) |
+ |
+ return sortable_value_list |
+ |
+ |
+def _SortableLabelValues(art, col_name, well_known_value_indexes): |
+ """Return a list of ints and strings for labels relevant to one UI column.""" |
+ sortable_value_list = [] |
+ for label in tracker_bizobj.GetLabels(art): |
+ idx_or_lex = well_known_value_indexes.get(label) |
+ if idx_or_lex == IGNORABLE_INDICATOR: |
+ continue # Label is known to not have the desired prefix. |
+ if idx_or_lex is None: |
+ if '-' not in label: |
+ # Skip an irrelevant OneWord label and remember to ignore it later. |
+ well_known_value_indexes[label] = IGNORABLE_INDICATOR |
+ continue |
+ key, value = label.lower().split('-', 1) |
+ if key == col_name: |
+ # Label is a key-value label with an odd-ball value, remember it |
+ idx_or_lex = value |
+ well_known_value_indexes[label] = value |
+ else: |
+ # Label was a key-value label that is not relevant to this column. |
+ # Remember to ignore it later. |
+ well_known_value_indexes[label] = IGNORABLE_INDICATOR |
+ continue |
+ |
+ sortable_value_list.append(idx_or_lex) |
+ |
+ return sortable_value_list |