| # Copyright 2018 The Chromium Authors |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| """A service for querying data for charts. |
| |
| Functions for querying the IssueSnapshot table and associated join tables. |
| """ |
| from __future__ import print_function |
| from __future__ import division |
| from __future__ import absolute_import |
| |
| import logging |
| import settings |
| import time |
| |
| from features import hotlist_helpers |
| from framework import framework_helpers |
| from framework import sql |
| from search import search_helpers |
| from tracker import tracker_bizobj |
| from tracker import tracker_helpers |
| from search import query2ast |
| from search import ast2select |
| from search import ast2ast |
| |
| |
| ISSUESNAPSHOT_TABLE_NAME = 'IssueSnapshot' |
| ISSUESNAPSHOT2CC_TABLE_NAME = 'IssueSnapshot2Cc' |
| ISSUESNAPSHOT2COMPONENT_TABLE_NAME = 'IssueSnapshot2Component' |
| ISSUESNAPSHOT2LABEL_TABLE_NAME = 'IssueSnapshot2Label' |
| |
| ISSUESNAPSHOT_COLS = ['id', 'issue_id', 'shard', 'project_id', 'local_id', |
| 'reporter_id', 'owner_id', 'status_id', 'period_start', 'period_end', |
| 'is_open'] |
| ISSUESNAPSHOT2CC_COLS = ['issuesnapshot_id', 'cc_id'] |
| ISSUESNAPSHOT2COMPONENT_COLS = ['issuesnapshot_id', 'component_id'] |
| ISSUESNAPSHOT2LABEL_COLS = ['issuesnapshot_id', 'label_id'] |
| |
| |
| class ChartService(object): |
| """Class for querying chart data.""" |
| |
| def __init__(self, config_service): |
| """Constructor for ChartService. |
| |
| Args: |
| config_service (ConfigService): An instance of ConfigService. |
| """ |
| self.config_service = config_service |
| |
| # Set up SQL table objects. |
| self.issuesnapshot_tbl = sql.SQLTableManager(ISSUESNAPSHOT_TABLE_NAME) |
| self.issuesnapshot2cc_tbl = sql.SQLTableManager( |
| ISSUESNAPSHOT2CC_TABLE_NAME) |
| self.issuesnapshot2component_tbl = sql.SQLTableManager( |
| ISSUESNAPSHOT2COMPONENT_TABLE_NAME) |
| self.issuesnapshot2label_tbl = sql.SQLTableManager( |
| ISSUESNAPSHOT2LABEL_TABLE_NAME) |
| |
| def QueryIssueSnapshots(self, cnxn, services, unixtime, effective_ids, |
| project, perms, group_by=None, label_prefix=None, |
| query=None, canned_query=None, hotlist=None): |
| """Queries historical issue counts grouped by label or component. |
| |
| Args: |
| cnxn: A MonorailConnection instance. |
| services: A Services instance. |
| unixtime: An integer representing the Unix time in seconds. |
| effective_ids: The effective User IDs associated with the current user. |
| project: A project object representing the current project. |
| perms: A permissions object associated with the current user. |
| group_by (str, optional): Which dimension to group by. Values can |
| be 'label', 'component', or None, in which case no grouping will |
| be applied. |
| label_prefix: Required when group_by is 'label.' Will limit the query to |
| only labels with the specified prefix (for example 'Pri'). |
| query (str, optional): A query string from the request to apply to |
| the snapshot query. |
| canned_query (str, optional): Parsed canned query applied to the query |
| scope. |
| hotlist (Hotlist, optional): Hotlist to search under (in lieu of project). |
| |
| Returns: |
| 1. A dict of {'2nd dimension or "total"': number of occurences}. |
| 2. A list of any unsupported query conditions in query. |
| 3. A boolean that is true if any results were capped. |
| """ |
| if hotlist: |
| # TODO(jeffcarp): Get project_ids in a more efficient manner. We can |
| # query for "SELECT DISTINCT(project_id)" for all issues in hotlist. |
| issues_list = services.issue.GetIssues(cnxn, |
| [hotlist_issue.issue_id for hotlist_issue in hotlist.items]) |
| hotlist_issues_project_ids = hotlist_helpers.GetAllProjectsOfIssues( |
| [issue for issue in issues_list]) |
| config_list = hotlist_helpers.GetAllConfigsOfProjects( |
| cnxn, hotlist_issues_project_ids, services) |
| project_config = tracker_bizobj.HarmonizeConfigs(config_list) |
| else: |
| project_config = services.config.GetProjectConfig(cnxn, |
| project.project_id) |
| |
| if project: |
| project_ids = [project.project_id] |
| else: |
| project_ids = hotlist_issues_project_ids |
| |
| try: |
| query_left_joins, query_where, unsupported_conds = self._QueryToWhere( |
| cnxn, services, project_config, query, canned_query, project_ids) |
| except ast2select.NoPossibleResults: |
| return {}, ['Invalid query.'], False |
| |
| restricted_label_ids = search_helpers.GetPersonalAtRiskLabelIDs( |
| cnxn, None, self.config_service, effective_ids, project, perms) |
| |
| left_joins = [ |
| ('Issue ON IssueSnapshot.issue_id = Issue.id', []), |
| ] |
| |
| if restricted_label_ids: |
| left_joins.append( |
| (('Issue2Label AS Forbidden_label' |
| ' ON Issue.id = Forbidden_label.issue_id' |
| ' AND Forbidden_label.label_id IN (%s)' % ( |
| sql.PlaceHolders(restricted_label_ids) |
| )), restricted_label_ids)) |
| |
| if effective_ids: |
| left_joins.append( |
| ('Issue2Cc AS I2cc' |
| ' ON Issue.id = I2cc.issue_id' |
| ' AND I2cc.cc_id IN (%s)' % sql.PlaceHolders(effective_ids), |
| effective_ids)) |
| |
| # TODO(jeffcarp): Handle case where there are issues with no labels. |
| where = [ |
| ('IssueSnapshot.period_start <= %s', [unixtime]), |
| ('IssueSnapshot.period_end > %s', [unixtime]), |
| ('Issue.is_spam = %s', [False]), |
| ('Issue.deleted = %s', [False]), |
| ] |
| if project_ids: |
| where.append( |
| ('IssueSnapshot.project_id IN (%s)' % sql.PlaceHolders(project_ids), |
| project_ids)) |
| |
| forbidden_label_clause = 'Forbidden_label.label_id IS NULL' |
| if effective_ids: |
| if restricted_label_ids: |
| forbidden_label_clause = ' OR %s' % forbidden_label_clause |
| else: |
| forbidden_label_clause = '' |
| |
| where.append( |
| (( |
| '(Issue.reporter_id IN (%s)' |
| ' OR Issue.owner_id IN (%s)' |
| ' OR I2cc.cc_id IS NOT NULL' |
| '%s)' |
| ) % ( |
| sql.PlaceHolders(effective_ids), sql.PlaceHolders(effective_ids), |
| forbidden_label_clause |
| ), |
| list(effective_ids) + list(effective_ids) |
| )) |
| else: |
| where.append((forbidden_label_clause, [])) |
| |
| if group_by == 'component': |
| cols = ['Comp.path', 'COUNT(IssueSnapshot.issue_id)'] |
| left_joins.extend([ |
| (('IssueSnapshot2Component AS Is2c ON' |
| ' Is2c.issuesnapshot_id = IssueSnapshot.id'), []), |
| ('ComponentDef AS Comp ON Comp.id = Is2c.component_id', []), |
| ]) |
| group_by = ['Comp.path'] |
| elif group_by == 'label': |
| cols = ['Lab.label', 'COUNT(IssueSnapshot.issue_id)'] |
| left_joins.extend([ |
| (('IssueSnapshot2Label AS Is2l' |
| ' ON Is2l.issuesnapshot_id = IssueSnapshot.id'), []), |
| ('LabelDef AS Lab ON Lab.id = Is2l.label_id', []), |
| ]) |
| |
| if not label_prefix: |
| raise ValueError('`label_prefix` required when grouping by label.') |
| |
| # TODO(jeffcarp): If LookupIDsOfLabelsMatching() is called on output, |
| # ensure regex is case-insensitive. |
| where.append(('LOWER(Lab.label) LIKE %s', [label_prefix.lower() + '-%'])) |
| group_by = ['Lab.label'] |
| elif group_by == 'open': |
| cols = ['IssueSnapshot.is_open', |
| 'COUNT(IssueSnapshot.issue_id) AS issue_count'] |
| group_by = ['IssueSnapshot.is_open'] |
| elif group_by == 'status': |
| left_joins.append(('StatusDef AS Stats ON ' \ |
| 'Stats.id = IssueSnapshot.status_id', [])) |
| cols = ['Stats.status', 'COUNT(IssueSnapshot.issue_id)'] |
| group_by = ['Stats.status'] |
| elif group_by == 'owner': |
| cols = ['IssueSnapshot.owner_id', 'COUNT(IssueSnapshot.issue_id)'] |
| group_by = ['IssueSnapshot.owner_id'] |
| elif not group_by: |
| cols = ['IssueSnapshot.issue_id'] |
| else: |
| raise ValueError('`group_by` must be label, component, ' \ |
| 'open, status, owner or None.') |
| |
| if query_left_joins: |
| left_joins.extend(query_left_joins) |
| |
| if query_where: |
| where.extend(query_where) |
| |
| if hotlist: |
| left_joins.extend([ |
| (('IssueSnapshot2Hotlist AS Is2h' |
| ' ON Is2h.issuesnapshot_id = IssueSnapshot.id' |
| ' AND Is2h.hotlist_id = %s'), [hotlist.hotlist_id]), |
| ]) |
| where.append( |
| ('Is2h.hotlist_id = %s', [hotlist.hotlist_id])) |
| |
| promises = [] |
| |
| for shard_id in range(settings.num_logical_shards): |
| count_stmt, stmt_args = self._BuildSnapshotQuery(cols=cols, |
| where=where, joins=left_joins, group_by=group_by, |
| shard_id=shard_id) |
| promises.append(framework_helpers.Promise(cnxn.Execute, |
| count_stmt, stmt_args, shard_id=shard_id)) |
| |
| shard_values_dict = {} |
| |
| search_limit_reached = False |
| |
| for promise in promises: |
| # Wait for each query to complete and add it to the dict. |
| shard_values = list(promise.WaitAndGetValue()) |
| |
| if not shard_values: |
| continue |
| if group_by: |
| for name, count in shard_values: |
| if count >= settings.chart_query_max_rows: |
| search_limit_reached = True |
| |
| shard_values_dict.setdefault(name, 0) |
| shard_values_dict[name] += count |
| else: |
| if shard_values[0][0] >= settings.chart_query_max_rows: |
| search_limit_reached = True |
| |
| shard_values_dict.setdefault('total', 0) |
| shard_values_dict['total'] += shard_values[0][0] |
| |
| unsupported_field_names = list(set([ |
| field.field_name |
| for cond in unsupported_conds |
| for field in cond.field_defs |
| ])) |
| |
| return shard_values_dict, unsupported_field_names, search_limit_reached |
| |
| def StoreIssueSnapshots(self, cnxn, issues, commit=True): |
| """Adds an IssueSnapshot and updates the previous one for each issue.""" |
| for issue in issues: |
| right_now = self._currentTime() |
| |
| # Update previous snapshot of current issue's end time to right now. |
| self.issuesnapshot_tbl.Update(cnxn, |
| delta={'period_end': right_now}, |
| where=[('IssueSnapshot.issue_id = %s', [issue.issue_id]), |
| ('IssueSnapshot.period_end = %s', |
| [settings.maximum_snapshot_period_end])], |
| commit=commit) |
| |
| config = self.config_service.GetProjectConfig(cnxn, issue.project_id) |
| period_end = settings.maximum_snapshot_period_end |
| is_open = tracker_helpers.MeansOpenInProject( |
| tracker_bizobj.GetStatus(issue), config) |
| shard = issue.issue_id % settings.num_logical_shards |
| status = tracker_bizobj.GetStatus(issue) |
| status_id = self.config_service.LookupStatusID( |
| cnxn, issue.project_id, status) or None |
| owner_id = tracker_bizobj.GetOwnerId(issue) or None |
| |
| issuesnapshot_rows = [(issue.issue_id, shard, issue.project_id, |
| issue.local_id, issue.reporter_id, owner_id, status_id, right_now, |
| period_end, is_open)] |
| |
| ids = self.issuesnapshot_tbl.InsertRows( |
| cnxn, ISSUESNAPSHOT_COLS[1:], |
| issuesnapshot_rows, |
| replace=True, commit=commit, |
| return_generated_ids=True) |
| issuesnapshot_id = ids[0] |
| |
| # Add all labels to IssueSnapshot2Label. |
| label_rows = [ |
| (issuesnapshot_id, |
| self.config_service.LookupLabelID(cnxn, issue.project_id, label)) |
| for label in tracker_bizobj.GetLabels(issue) |
| ] |
| self.issuesnapshot2label_tbl.InsertRows( |
| cnxn, ISSUESNAPSHOT2LABEL_COLS, |
| label_rows, replace=True, commit=commit) |
| |
| # Add all CCs to IssueSnapshot2Cc. |
| cc_rows = [ |
| (issuesnapshot_id, cc_id) |
| for cc_id in tracker_bizobj.GetCcIds(issue) |
| ] |
| self.issuesnapshot2cc_tbl.InsertRows( |
| cnxn, ISSUESNAPSHOT2CC_COLS, |
| cc_rows, |
| replace=True, commit=commit) |
| |
| # Add all components to IssueSnapshot2Component. |
| component_rows = [ |
| (issuesnapshot_id, component_id) |
| for component_id in issue.component_ids |
| ] |
| self.issuesnapshot2component_tbl.InsertRows( |
| cnxn, ISSUESNAPSHOT2COMPONENT_COLS, |
| component_rows, |
| replace=True, commit=commit) |
| |
| # Add all components to IssueSnapshot2Hotlist. |
| # This is raw SQL to obviate passing FeaturesService down through |
| # the call stack wherever this function is called. |
| # TODO(jrobbins): sort out dependencies between service classes. |
| cnxn.Execute(''' |
| INSERT INTO IssueSnapshot2Hotlist (issuesnapshot_id, hotlist_id) |
| SELECT %s, hotlist_id FROM Hotlist2Issue WHERE issue_id = %s |
| ''', [issuesnapshot_id, issue.issue_id]) |
| |
| def ExpungeHotlistsFromIssueSnapshots(self, cnxn, hotlist_ids, commit=True): |
| """Expunge the existence of hotlists from issue snapshots. |
| |
| Args: |
| cnxn: connection to SQL database. |
| hotlist_ids: list of hotlist_ids for hotlists we want to delete. |
| commit: set to False to skip the DB commit and do it in a caller. |
| """ |
| vals_ph = sql.PlaceHolders(hotlist_ids) |
| cnxn.Execute( |
| 'DELETE FROM IssueSnapshot2Hotlist ' |
| 'WHERE hotlist_id IN ({vals_ph})'.format(vals_ph=vals_ph), |
| hotlist_ids, |
| commit=commit) |
| |
| def _currentTime(self): |
| """This is a separate method so it can be mocked by tests.""" |
| return time.time() |
| |
| def _QueryToWhere(self, cnxn, services, project_config, query, canned_query, |
| project_ids): |
| """Parses a query string into LEFT JOIN and WHERE conditions. |
| |
| Args: |
| cnxn: A MonorailConnection instance. |
| services: A Services instance. |
| project_config: The configuration for the given project. |
| query (string): The query to parse. |
| canned_query (string): The supplied canned query. |
| project_ids: The current project ID(s). |
| |
| Returns: |
| 1. A list of LEFT JOIN clauses for the SQL query. |
| 2. A list of WHERE clases for the SQL query. |
| 3. A list of query conditions that are unsupported with snapshots. |
| """ |
| if not (query or canned_query): |
| return [], [], [] |
| |
| query = query or '' |
| scope = canned_query or '' |
| |
| query_ast = query2ast.ParseUserQuery(query, scope, |
| query2ast.BUILTIN_ISSUE_FIELDS, project_config) |
| query_ast = ast2ast.PreprocessAST(cnxn, query_ast, project_ids, |
| services, project_config) |
| left_joins, where, unsupported = ast2select.BuildSQLQuery(query_ast, |
| snapshot_mode=True) |
| |
| return left_joins, where, unsupported |
| |
| def _BuildSnapshotQuery(self, cols, where, joins, group_by, shard_id): |
| """Given SQL arguments, executes a snapshot COUNT query.""" |
| stmt = sql.Statement.MakeSelect('IssueSnapshot', cols, distinct=True) |
| stmt.AddJoinClauses(joins, left=True) |
| stmt.AddWhereTerms(where + [('IssueSnapshot.shard = %s', [shard_id])]) |
| if group_by: |
| stmt.AddGroupByTerms(group_by) |
| stmt.SetLimitAndOffset(limit=settings.chart_query_max_rows, offset=0) |
| stmt_str, stmt_args = stmt.Generate() |
| if group_by: |
| if group_by[0] == 'IssueSnapshot.is_open': |
| count_stmt = ('SELECT IF(results.is_open = 1, "Opened", "Closed") ' \ |
| 'AS bool_open, results.issue_count ' \ |
| 'FROM (%s) AS results' % stmt_str) |
| else: |
| count_stmt = stmt_str |
| else: |
| count_stmt = 'SELECT COUNT(results.issue_id) FROM (%s) AS results' % ( |
| stmt_str) |
| return count_stmt, stmt_args |