blob: cbcb0b0b71c13f2ca66db9499d3a9c48dfffe2c1 [file] [log] [blame]
# Copyright 2016 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Convert a user's issue search AST into SQL clauses.
The main query is done on the Issues table.
+ Some simple conditions are implemented as WHERE conditions on the Issue
table rows. These are generated by the _Compare() function.
+ More complex conditions are implemented via a "LEFT JOIN ... ON ..." clause
plus a check in the WHERE clause to select only rows where the join's ON
condition was satisfied. These are generated by appending a clause to
the left_joins list plus calling _CompareAlreadyJoined(). Each such left
join defines a unique alias to keep it separate from other conditions.
The functions that generate SQL snippets need to insert table names, column
names, alias names, and value placeholders into the generated string. These
functions use the string format() method and the "{varname}" syntax to avoid
confusion with the "%s" syntax used for SQL value placeholders.
"""
from __future__ import print_function
from __future__ import division
from __future__ import absolute_import
import logging
from framework import sql
from mrproto import ast_pb2
from mrproto import tracker_pb2
from search import query2ast
from services import tracker_fulltext
NATIVE_SEARCHABLE_FIELDS = {
'id': 'local_id',
'is_spam': 'is_spam',
'stars': 'star_count',
'attachments': 'attachment_count',
'opened': 'opened',
'closed': 'closed',
'modified': 'modified',
'ownermodified': 'owner_modified',
'statusmodified': 'status_modified',
'componentmodified': 'component_modified',
}
def BuildSQLQuery(query_ast, snapshot_mode=False):
"""Translate the user's query into an SQL query.
Args:
query_ast: user query abstract syntax tree parsed by query2ast.py.
Returns:
A pair of lists (left_joins, where) to use when building the SQL SELECT
statement. Each of them is a list of (str, [val, ...]) pairs.
"""
left_joins = []
where = []
unsupported_conds = []
# OR-queries are broken down into multiple simpler queries before they
# are sent to the backends, so we should never see an "OR"..
assert len(query_ast.conjunctions) == 1, 'OR-query should have been split'
conj = query_ast.conjunctions[0]
for cond_num, cond in enumerate(conj.conds):
cond_left_joins, cond_where, unsupported = _ProcessCond(cond_num, cond,
snapshot_mode)
left_joins.extend(cond_left_joins)
where.extend(cond_where)
unsupported_conds.extend(unsupported)
return left_joins, where, unsupported_conds
def _ProcessBlockedOnIDCond(cond, alias, _spare_alias, snapshot_mode):
"""Convert a blockedon_id=issue_id cond to SQL."""
return _ProcessRelatedIDCond(cond, alias, 'blockedon',
snapshot_mode=snapshot_mode)
def _ProcessBlockingIDCond(cond, alias, _spare_alias, snapshot_mode):
"""Convert a blocking_id:1,2 cond to SQL."""
return _ProcessRelatedIDCond(cond, alias, 'blockedon', reverse_relation=True,
snapshot_mode=snapshot_mode)
def _ProcessMergedIntoIDCond(cond, alias, _spare_alias, snapshot_mode):
"""Convert a mergedinto:1,2 cond to SQL."""
return _ProcessRelatedIDCond(cond, alias, 'mergedinto',
snapshot_mode=snapshot_mode)
def _ProcessRelatedIDCond(cond, alias, kind, reverse_relation=False,
snapshot_mode=False):
"""Convert either blocking_id, blockedon_id, or mergedinto_id cond to SQL.
Normally, we query for issue_id values where the dst_issue_id matches the
IDs specified in the cond. However, when reverse_relation is True, we
query for dst_issue_id values where issue_id matches. This is done for
blockedon_id.
"""
if snapshot_mode:
return [], [], [cond]
matching_issue_col = 'issue_id' if reverse_relation else 'dst_issue_id'
ret_issue_col = 'dst_issue_id' if reverse_relation else 'issue_id'
ext_kind = 'blocking' if reverse_relation else kind
left_join = []
where = []
issue_ids = cond.int_values
ext_issue_ids = cond.str_values
# Filter has:blockedon and has:blocking.
if (not issue_ids) and (not ext_issue_ids):
kind_cond_str, kind_cond_args = _Compare(
alias, ast_pb2.QueryOp.EQ, tracker_pb2.FieldTypes.STR_TYPE, 'kind',
[kind])
left_join_str = (
'IssueRelation AS {alias} ON Issue.id = {alias}.{ret_issue_col} AND '
'{kind_cond}').format(
alias=alias, ret_issue_col=ret_issue_col, kind_cond=kind_cond_str)
left_join_args = kind_cond_args
left_join.append((left_join_str, left_join_args))
kind_cond_str, kind_cond_args = _Compare(
'DIR', ast_pb2.QueryOp.EQ, tracker_pb2.FieldTypes.STR_TYPE, 'kind',
[ext_kind])
ext_left_join_str = ('DanglingIssueRelation AS DIR ON '
'Issue.id = DIR.issue_id AND {kind_cond}').format(
kind_cond=kind_cond_str)
left_join.append((ext_left_join_str, kind_cond_args))
where_str, where_args = _CompareAlreadyJoined(alias,
cond.op, ret_issue_col)
ext_where_str, ext_where_args = _CompareAlreadyJoined('DIR',
cond.op, 'issue_id')
where.append(('({where} OR {ext_where})'.format(
where=where_str, ext_where=ext_where_str),
where_args + ext_where_args))
# Filter kind using provided issue ids.
if issue_ids:
kind_cond_str, kind_cond_args = _Compare(
alias, ast_pb2.QueryOp.EQ, tracker_pb2.FieldTypes.STR_TYPE, 'kind',
[kind])
left_join_str = (
'IssueRelation AS {alias} ON Issue.id = {alias}.{ret_issue_col} AND '
'{kind_cond}').format(
alias=alias, ret_issue_col=ret_issue_col, kind_cond=kind_cond_str)
left_join_args = kind_cond_args
related_cond_str, related_cond_args = _Compare(
alias, ast_pb2.QueryOp.EQ, tracker_pb2.FieldTypes.INT_TYPE,
matching_issue_col, issue_ids)
left_join_str += ' AND {related_cond}'.format(related_cond=related_cond_str)
left_join_args += related_cond_args
left_join.append((left_join_str, left_join_args))
where.append(_CompareAlreadyJoined(alias, cond.op, ret_issue_col))
# Filter kind using provided external issue ids.
if ext_issue_ids:
kind_cond_str, kind_cond_args = _Compare(
'DIR', ast_pb2.QueryOp.EQ, tracker_pb2.FieldTypes.STR_TYPE, 'kind',
[ext_kind])
ext_left_join_str = ('DanglingIssueRelation AS DIR ON '
'Issue.id = DIR.issue_id AND {kind_cond}').format(
kind_cond=kind_cond_str)
related_cond_str, related_cond_args = _Compare(
'DIR', ast_pb2.QueryOp.EQ, tracker_pb2.FieldTypes.INT_TYPE,
'ext_issue_identifier', ext_issue_ids)
ext_left_join_str += ' AND {related_cond}'.format(
related_cond=related_cond_str)
kind_cond_args += related_cond_args
left_join.append((ext_left_join_str, kind_cond_args))
where.append(_CompareAlreadyJoined('DIR', cond.op, 'issue_id'))
return left_join, where, []
def _GetFieldTypeAndValues(cond):
"""Returns the field type and values to use from the condition.
This function should be used when we do not know what values are present on
the condition. Eg: cond.int_values could be set if ast2ast.py preprocessing is
first done. If that preprocessing is not done then str_values could be set
instead.
If both int values and str values exist on the condition then the int values
are returned.
"""
if cond.int_values:
return tracker_pb2.FieldTypes.INT_TYPE, cond.int_values
else:
return tracker_pb2.FieldTypes.STR_TYPE, cond.str_values
def _ProcessOwnerCond(cond, alias, _spare_alias, snapshot_mode):
"""Convert an owner:substring cond to SQL."""
if snapshot_mode:
left_joins = [(
'User AS {alias} ON '
'IssueSnapshot.owner_id = {alias}.user_id'.format(alias=alias),
[])]
else:
left_joins = [(
'User AS {alias} ON (Issue.owner_id = {alias}.user_id '
'OR Issue.derived_owner_id = {alias}.user_id)'.format(alias=alias),
[])]
where = [_Compare(alias, cond.op, tracker_pb2.FieldTypes.STR_TYPE, 'email',
cond.str_values)]
return left_joins, where, []
def _ProcessOwnerIDCond(cond, _alias, _spare_alias, snapshot_mode):
"""Convert an owner_id=user_id cond to SQL."""
if snapshot_mode:
field_type, field_values = _GetFieldTypeAndValues(cond)
explicit_str, explicit_args = _Compare(
'IssueSnapshot', cond.op, field_type, 'owner_id', field_values)
where = [(explicit_str, explicit_args)]
else:
field_type, field_values = _GetFieldTypeAndValues(cond)
explicit_str, explicit_args = _Compare(
'Issue', cond.op, field_type, 'owner_id', field_values)
derived_str, derived_args = _Compare(
'Issue', cond.op, field_type, 'derived_owner_id', field_values)
if cond.op in (ast_pb2.QueryOp.NE, ast_pb2.QueryOp.NOT_TEXT_HAS):
where = [(explicit_str, explicit_args), (derived_str, derived_args)]
else:
if cond.op == ast_pb2.QueryOp.IS_NOT_DEFINED:
op = ' AND '
else:
op = ' OR '
where = [
('(' + explicit_str + op + derived_str + ')',
explicit_args + derived_args)]
return [], where, []
def _ProcessOwnerLastVisitCond(cond, alias, _spare_alias, snapshot_mode):
"""Convert an ownerlastvisit<timestamp cond to SQL."""
# TODO(jeffcarp): It is possible to support this on snapshots.
if snapshot_mode:
return [], [], [cond]
left_joins = [(
'User AS {alias} '
'ON (Issue.owner_id = {alias}.user_id OR '
'Issue.derived_owner_id = {alias}.user_id)'.format(alias=alias),
[])]
where = [_Compare(alias, cond.op, tracker_pb2.FieldTypes.INT_TYPE,
'last_visit_timestamp', cond.int_values)]
return left_joins, where, []
def _ProcessIsOwnerBouncing(cond, alias, _spare_alias, snapshot_mode):
"""Convert an is:ownerbouncing cond to SQL."""
if snapshot_mode:
return [], [], [cond]
left_joins = [(
'User AS {alias} '
'ON (Issue.owner_id = {alias}.user_id OR '
'Issue.derived_owner_id = {alias}.user_id)'.format(alias=alias),
[])]
if cond.op == ast_pb2.QueryOp.EQ:
op = ast_pb2.QueryOp.IS_DEFINED
else:
op = ast_pb2.QueryOp.IS_NOT_DEFINED
where = [_Compare(alias, op, tracker_pb2.FieldTypes.INT_TYPE,
'email_bounce_timestamp', [])]
return left_joins, where, []
def _ProcessReporterCond(cond, alias, _spare_alias, snapshot_mode):
"""Convert a reporter:substring cond to SQL."""
if snapshot_mode:
left_joins = [(
'User AS {alias} ON IssueSnapshot.reporter_id = {alias}.user_id'.format(
alias=alias), [])]
else:
left_joins = [(
'User AS {alias} ON Issue.reporter_id = {alias}.user_id'.format(
alias=alias), [])]
where = [_Compare(alias, cond.op, tracker_pb2.FieldTypes.STR_TYPE, 'email',
cond.str_values)]
return left_joins, where, []
def _ProcessReporterIDCond(cond, _alias, _spare_alias, snapshot_mode):
"""Convert a reporter_ID=user_id cond to SQL."""
field_type, field_values = _GetFieldTypeAndValues(cond)
if snapshot_mode:
where = [_Compare(
'IssueSnapshot', cond.op, field_type, 'reporter_id', field_values)]
else:
where = [_Compare(
'Issue', cond.op, field_type, 'reporter_id', field_values)]
return [], where, []
def _ProcessCcCond(cond, alias, user_alias, snapshot_mode):
"""Convert a cc:substring cond to SQL."""
email_cond_str, email_cond_args = _Compare(
user_alias, ast_pb2.QueryOp.TEXT_HAS, tracker_pb2.FieldTypes.STR_TYPE,
'email', cond.str_values)
if snapshot_mode:
left_joins = [(
'(IssueSnapshot2Cc AS {alias} JOIN User AS {user_alias} '
'ON {alias}.cc_id = {user_alias}.user_id AND {email_cond}) '
'ON IssueSnapshot.id = {alias}.issuesnapshot_id'.format(
alias=alias, user_alias=user_alias, email_cond=email_cond_str),
email_cond_args)]
else:
# Note: email_cond_str will have parens, if needed.
left_joins = [(
'(Issue2Cc AS {alias} JOIN User AS {user_alias} '
'ON {alias}.cc_id = {user_alias}.user_id AND {email_cond}) '
'ON Issue.id = {alias}.issue_id AND '
'Issue.shard = {alias}.issue_shard'.format(
alias=alias, user_alias=user_alias, email_cond=email_cond_str),
email_cond_args)]
where = [_CompareAlreadyJoined(user_alias, cond.op, 'email')]
return left_joins, where, []
def _ProcessCcIDCond(cond, alias, _spare_alias, snapshot_mode):
"""Convert a cc_id=user_id cond to SQL."""
if snapshot_mode:
join_str = (
'IssueSnapshot2Cc AS {alias} '
'ON IssueSnapshot.id = {alias}.issuesnapshot_id'.format(alias=alias))
else:
join_str = (
'Issue2Cc AS {alias} ON Issue.id = {alias}.issue_id AND '
'Issue.shard = {alias}.issue_shard'.format(
alias=alias))
if cond.op in (ast_pb2.QueryOp.IS_DEFINED, ast_pb2.QueryOp.IS_NOT_DEFINED):
left_joins = [(join_str, [])]
else:
field_type, field_values = _GetFieldTypeAndValues(cond)
cond_str, cond_args = _Compare(
alias, ast_pb2.QueryOp.EQ, field_type, 'cc_id', field_values)
left_joins = [(join_str + ' AND ' + cond_str, cond_args)]
where = [_CompareAlreadyJoined(alias, cond.op, 'cc_id')]
return left_joins, where, []
def _ProcessStarredByCond(cond, alias, user_alias, snapshot_mode):
"""Convert a starredby:substring cond to SQL."""
if snapshot_mode:
return [], [], [cond]
email_cond_str, email_cond_args = _Compare(
user_alias, cond.op, tracker_pb2.FieldTypes.STR_TYPE, 'email',
cond.str_values)
# Note: email_cond_str will have parens, if needed.
left_joins = [(
'(IssueStar AS {alias} JOIN User AS {user_alias} '
'ON {alias}.user_id = {user_alias}.user_id AND {email_cond}) '
'ON Issue.id = {alias}.issue_id'.format(
alias=alias, user_alias=user_alias, email_cond=email_cond_str),
email_cond_args)]
where = [_CompareAlreadyJoined(user_alias, cond.op, 'email')]
return left_joins, where, []
def _ProcessStarredByIDCond(cond, alias, _spare_alias, snapshot_mode):
"""Convert a starredby_id=user_id cond to SQL."""
if snapshot_mode:
return [], [], [cond]
join_str = 'IssueStar AS {alias} ON Issue.id = {alias}.issue_id'.format(
alias=alias)
if cond.op in (ast_pb2.QueryOp.IS_DEFINED, ast_pb2.QueryOp.IS_NOT_DEFINED):
left_joins = [(join_str, [])]
else:
field_type, field_values = _GetFieldTypeAndValues(cond)
cond_str, cond_args = _Compare(
alias, ast_pb2.QueryOp.EQ, field_type, 'user_id', field_values)
left_joins = [(join_str + ' AND ' + cond_str, cond_args)]
where = [_CompareAlreadyJoined(alias, cond.op, 'user_id')]
return left_joins, where, []
def _ProcessCommentByCond(cond, alias, user_alias, snapshot_mode):
"""Convert a commentby:substring cond to SQL."""
if snapshot_mode:
return [], [], [cond]
email_cond_str, email_cond_args = _Compare(
user_alias, ast_pb2.QueryOp.TEXT_HAS, tracker_pb2.FieldTypes.STR_TYPE,
'email', cond.str_values)
# Note: email_cond_str will have parens, if needed.
left_joins = [(
'(Comment AS {alias} JOIN User AS {user_alias} '
'ON {alias}.commenter_id = {user_alias}.user_id AND {email_cond}) '
'ON Issue.id = {alias}.issue_id AND '
'{alias}.deleted_by IS NULL'.format(
alias=alias, user_alias=user_alias, email_cond=email_cond_str),
email_cond_args)]
where = [_CompareAlreadyJoined(user_alias, cond.op, 'email')]
return left_joins, where, []
def _ProcessCommentByIDCond(cond, alias, _spare_alias, snapshot_mode):
"""Convert a commentby_id=user_id cond to SQL."""
if snapshot_mode:
return [], [], [cond]
field_type, field_values = _GetFieldTypeAndValues(cond)
commenter_cond_str, commenter_cond_args = _Compare(
alias, ast_pb2.QueryOp.EQ, field_type, 'commenter_id', field_values)
left_joins = [(
'Comment AS {alias} ON Issue.id = {alias}.issue_id AND '
'{commenter_cond} AND '
'{alias}.deleted_by IS NULL'.format(
alias=alias, commenter_cond=commenter_cond_str),
commenter_cond_args)]
where = [_CompareAlreadyJoined(alias, cond.op, 'commenter_id')]
return left_joins, where, []
def _ProcessStatusIDCond(cond, _alias, _spare_alias, snapshot_mode):
"""Convert a status_id=ID cond to SQL."""
field_type, field_values = _GetFieldTypeAndValues(cond)
if snapshot_mode:
explicit_str, explicit_args = _Compare(
'IssueSnapshot', cond.op, field_type, 'status_id', field_values)
where = [(explicit_str, explicit_args)]
else:
explicit_str, explicit_args = _Compare(
'Issue', cond.op, field_type, 'status_id', field_values)
derived_str, derived_args = _Compare(
'Issue', cond.op, field_type, 'derived_status_id', field_values)
if cond.op in (ast_pb2.QueryOp.IS_NOT_DEFINED, ast_pb2.QueryOp.NE):
where = [(explicit_str, explicit_args), (derived_str, derived_args)]
else:
where = [
('(' + explicit_str + ' OR ' + derived_str + ')',
explicit_args + derived_args)]
return [], where, []
def _ProcessSummaryCond(cond, alias, _spare_alias, snapshot_mode):
"""Convert a summary="exact string" cond to SQL."""
left_joins = []
where = []
field_type, field_values = _GetFieldTypeAndValues(cond)
if snapshot_mode:
return [], [], [cond]
elif cond.op in (ast_pb2.QueryOp.EQ, ast_pb2.QueryOp.NE,
ast_pb2.QueryOp.GT, ast_pb2.QueryOp.LT,
ast_pb2.QueryOp.GE, ast_pb2.QueryOp.LE,
ast_pb2.QueryOp.IS_DEFINED, ast_pb2.QueryOp.IS_NOT_DEFINED):
summary_cond_str, summary_cond_args = _Compare(
alias, cond.op, field_type, 'summary', field_values)
left_joins = [(
'IssueSummary AS {alias} ON Issue.id = {alias}.issue_id AND '
'{summary_cond}'.format(
alias=alias, summary_cond=summary_cond_str),
summary_cond_args)]
where = [_CompareAlreadyJoined(alias, ast_pb2.QueryOp.EQ, 'issue_id')]
return left_joins, where, []
def _ProcessLabelIDCond(cond, alias, _spare_alias, snapshot_mode):
"""Convert a label_id=ID cond to SQL."""
if snapshot_mode:
join_str = (
'IssueSnapshot2Label AS {alias} '
'ON IssueSnapshot.id = {alias}.issuesnapshot_id'.format(alias=alias))
else:
join_str = (
'Issue2Label AS {alias} ON Issue.id = {alias}.issue_id AND '
'Issue.shard = {alias}.issue_shard'.format(alias=alias))
field_type, field_values = _GetFieldTypeAndValues(cond)
if not field_values and cond.op == ast_pb2.QueryOp.NE:
return [], [], []
cond_str, cond_args = _Compare(
alias, ast_pb2.QueryOp.EQ, field_type, 'label_id', field_values)
left_joins = [(join_str + ' AND ' + cond_str, cond_args)]
where = [_CompareAlreadyJoined(alias, cond.op, 'label_id')]
return left_joins, where, []
def _ProcessComponentIDCond(cond, alias, _spare_alias, snapshot_mode):
"""Convert a component_id=ID cond to SQL."""
# This is a built-in field, so it shadows any other fields w/ the same name.
if snapshot_mode:
join_str = (
'IssueSnapshot2Component AS {alias} '
'ON IssueSnapshot.id = {alias}.issuesnapshot_id'.format(alias=alias))
else:
join_str = (
'Issue2Component AS {alias} ON Issue.id = {alias}.issue_id AND '
'Issue.shard = {alias}.issue_shard'.format(alias=alias))
if cond.op in (ast_pb2.QueryOp.IS_DEFINED, ast_pb2.QueryOp.IS_NOT_DEFINED):
left_joins = [(join_str, [])]
else:
field_type, field_values = _GetFieldTypeAndValues(cond)
cond_str, cond_args = _Compare(
alias, ast_pb2.QueryOp.EQ, field_type, 'component_id', field_values)
left_joins = [(join_str + ' AND ' + cond_str, cond_args)]
where = [_CompareAlreadyJoined(alias, cond.op, 'component_id')]
return left_joins, where, []
# TODO(jojang): monorail:3819, check for cond.phase_name and process
# appropriately so users can search 'Canary.UXReview-status:Approved'
def _ProcessApprovalFieldCond(cond, alias, user_alias, snapshot_mode):
"""Convert a custom approval field cond to SQL."""
if snapshot_mode:
return [], [], [cond]
approval_fd = cond.field_defs[0]
left_joins = []
join_str_tmpl = (
'{tbl_name} AS {alias} ON Issue.id = {alias}.issue_id AND '
'{alias}.approval_id = %s')
join_args = [approval_fd.field_id]
val_type, values = _GetFieldTypeAndValues(cond)
if val_type is tracker_pb2.FieldTypes.STR_TYPE:
values = [val.lower() for val in values]
# TODO(jojwang):monorail:3809, check if there is a cond.key_suffx.
# status, approver should always have a value, so 'has:UXReview-approver'
# should return the same issues as 'has:UXReview'.
# There will not always be values approval.setter_id and approval.set_on
# and the current code would not process 'has:UXReview-by' correctly.
if cond.op in (
ast_pb2.QueryOp.IS_DEFINED, ast_pb2.QueryOp.IS_NOT_DEFINED):
join_str = join_str_tmpl.format(
tbl_name='Issue2ApprovalValue', alias=alias)
left_joins = [(join_str, join_args)]
else:
op = cond.op
if op == ast_pb2.QueryOp.NE:
op = ast_pb2.QueryOp.EQ # Negation is done in WHERE clause.
elif op == ast_pb2.QueryOp.NOT_TEXT_HAS:
op = ast_pb2.QueryOp.TEXT_HAS
if (not cond.key_suffix) or cond.key_suffix == query2ast.STATUS_SUFFIX:
tbl_str = 'Issue2ApprovalValue'
cond_str, cond_args = _Compare(
alias, op, val_type, 'status', values)
elif cond.key_suffix == query2ast.SET_ON_SUFFIX:
tbl_str = 'Issue2ApprovalValue'
cond_str, cond_args = _Compare(
alias, op, val_type, 'set_on', values)
elif cond.key_suffix in [
query2ast.APPROVER_SUFFIX, query2ast.SET_BY_SUFFIX]:
if cond.key_suffix == query2ast.SET_BY_SUFFIX:
tbl_str = 'Issue2ApprovalValue'
col_name = 'setter_id'
else:
tbl_str = 'IssueApproval2Approver'
col_name = 'approver_id'
if val_type == tracker_pb2.FieldTypes.INT_TYPE:
cond_str, cond_args = _Compare(
alias, op, val_type, col_name, values)
else:
email_cond_str, email_cond_args = _Compare(
user_alias, op, val_type, 'email', values)
left_joins.append((
'User AS {user_alias} ON {email_cond}'.format(
user_alias=user_alias, email_cond=email_cond_str),
email_cond_args))
cond_str = '{alias}.{col_name} = {user_alias}.user_id'.format(
alias=alias, col_name=col_name, user_alias=user_alias)
cond_args = []
if cond_str or cond_args:
join_str = join_str_tmpl.format(tbl_name=tbl_str, alias=alias)
join_str += ' AND ' + cond_str
join_args.extend(cond_args)
left_joins.append((join_str, join_args))
where = [_CompareAlreadyJoined(alias, cond.op, 'approval_id')]
return left_joins, where, []
def _ProcessCustomFieldCond(
cond, alias, user_alias, phase_alias, snapshot_mode):
"""Convert a custom field cond to SQL."""
if snapshot_mode:
return [], [], [cond]
# TODO(jrobbins): handle ambiguous field names that map to multiple
# field definitions, especially for cross-project search.
field_def = cond.field_defs[0]
field_type = field_def.field_type
left_joins = []
join_str = (
'Issue2FieldValue AS {alias} ON Issue.id = {alias}.issue_id AND '
'Issue.shard = {alias}.issue_shard AND '
'{alias}.field_id = %s'.format(alias=alias))
join_args = [field_def.field_id]
if cond.op not in (
ast_pb2.QueryOp.IS_DEFINED, ast_pb2.QueryOp.IS_NOT_DEFINED):
op = cond.op
if op == ast_pb2.QueryOp.NE:
op = ast_pb2.QueryOp.EQ # Negation is done in WHERE clause.
if field_type == tracker_pb2.FieldTypes.INT_TYPE:
cond_str, cond_args = _Compare(
alias, op, field_type, 'int_value', cond.int_values)
elif field_type == tracker_pb2.FieldTypes.STR_TYPE:
cond_str, cond_args = _Compare(
alias, op, field_type, 'str_value', cond.str_values)
elif field_type == tracker_pb2.FieldTypes.USER_TYPE:
if cond.int_values:
cond_str, cond_args = _Compare(
alias, op, field_type, 'user_id', cond.int_values)
else:
email_cond_str, email_cond_args = _Compare(
user_alias, op, field_type, 'email', cond.str_values)
left_joins.append((
'User AS {user_alias} ON {email_cond}'.format(
user_alias=user_alias, email_cond=email_cond_str),
email_cond_args))
cond_str = '{alias}.user_id = {user_alias}.user_id'.format(
alias=alias, user_alias=user_alias)
cond_args = []
elif field_type == tracker_pb2.FieldTypes.URL_TYPE:
cond_str, cond_args = _Compare(
alias, op, field_type, 'url_value', cond.str_values)
if field_type == tracker_pb2.FieldTypes.DATE_TYPE:
cond_str, cond_args = _Compare(
alias, op, field_type, 'date_value', cond.int_values)
if cond_str or cond_args:
join_str += ' AND ' + cond_str
join_args.extend(cond_args)
if cond.phase_name:
phase_cond_str, phase_cond_args = _Compare(
phase_alias, ast_pb2.QueryOp.EQ, tracker_pb2.FieldTypes.STR_TYPE,
'name', [cond.phase_name])
left_joins.append((
'IssuePhaseDef AS {phase_alias} ON {phase_cond}'.format(
phase_alias=phase_alias, phase_cond=phase_cond_str),
phase_cond_args))
cond_str = '{alias}.phase_id = {phase_alias}.id'.format(
alias=alias, phase_alias=phase_alias)
cond_args = []
join_str += ' AND ' + cond_str
join_args.extend(cond_args)
left_joins.append((join_str, join_args))
where = [_CompareAlreadyJoined(alias, cond.op, 'field_id')]
return left_joins, where, []
def _ProcessAttachmentCond(cond, alias, _spare_alias, snapshot_mode):
"""Convert has:attachment and -has:attachment cond to SQL."""
if snapshot_mode:
return [], [], [cond]
if cond.op in (ast_pb2.QueryOp.IS_DEFINED, ast_pb2.QueryOp.IS_NOT_DEFINED):
left_joins = []
where = [_Compare('Issue', cond.op, tracker_pb2.FieldTypes.INT_TYPE,
'attachment_count', cond.int_values)]
else:
field_def = cond.field_defs[0]
field_type = field_def.field_type
left_joins = [
('Attachment AS {alias} ON Issue.id = {alias}.issue_id AND '
'{alias}.deleted = %s'.format(alias=alias),
[False])]
where = [_Compare(alias, cond.op, field_type, 'filename', cond.str_values)]
return left_joins, where, []
def _ProcessHotlistIDCond(cond, alias, _spare_alias, snapshot_mode):
"""Convert hotlist_id=IDS cond to SQL."""
if snapshot_mode:
join_str = (
'IssueSnapshot2Hotlist AS {alias} '
'ON IssueSnapshot.id = {alias}.issuesnapshot_id'.format(alias=alias))
else:
join_str = (
'Hotlist2Issue AS {alias} ON Issue.id = {alias}.issue_id'.format(
alias=alias))
field_type, field_values = _GetFieldTypeAndValues(cond)
if not field_values and cond.op == ast_pb2.QueryOp.NE:
return [], [], []
cond_str, cond_args = _Compare(
alias, ast_pb2.QueryOp.EQ, field_type, 'hotlist_id', field_values)
left_joins = [(join_str + ' AND ' + cond_str, cond_args)]
where = [_CompareAlreadyJoined(alias, cond.op, 'hotlist_id')]
return left_joins, where, []
def _ProcessHotlistCond(cond, alias, _spare_alias, snapshot_mode):
"""Convert hotlist=user:hotlist-name to SQL"""
# hotlist conditions that reach this function definitely have invalid
# user_name/id/email. This validity was determined in
# ast2ast._PreprocessHotlistCond. Any possible user identification is ignored.
hotlist_substrings = []
for val in cond.str_values:
substring = val.split(':')[-1]
if substring:
hotlist_substrings.append(substring)
hotlist_cond_str, hotlist_cond_args = _Compare(
alias, ast_pb2.QueryOp.TEXT_HAS, tracker_pb2.FieldTypes.STR_TYPE,
'name', hotlist_substrings)
if snapshot_mode:
left_joins = [(
'(IssueSnapshot2Hotlist JOIN Hotlist AS {alias} '
'ON IssueSnapshot2Hotlist.hotlist_id = {alias}.id AND {hotlist_cond}) '
'ON IssueSnapshot.id = IssueSnapshot2Hotlist.issuesnapshot_id'.format(
alias=alias, hotlist_cond=hotlist_cond_str), hotlist_cond_args)]
else:
left_joins = [(
'(Hotlist2Issue JOIN Hotlist AS {alias} '
'ON Hotlist2Issue.hotlist_id = {alias}.id AND {hotlist_cond}) '
'ON Issue.id = Hotlist2Issue.issue_id'.format(
alias=alias, hotlist_cond=hotlist_cond_str), hotlist_cond_args)]
where = [_CompareAlreadyJoined(alias, cond.op, 'name')]
return left_joins, where, []
def _ProcessPhaseCond(cond, alias, phase_alias, _snapshot_mode):
"""Convert gate:<phase_name> to SQL."""
op = cond.op
if cond.op == ast_pb2.QueryOp.NE:
op = ast_pb2.QueryOp.EQ
elif cond.op == ast_pb2.QueryOp.NOT_TEXT_HAS:
op = ast_pb2.QueryOp.TEXT_HAS
cond_str, cond_args = _Compare(
phase_alias, op, tracker_pb2.FieldTypes.STR_TYPE,
'name', cond.str_values)
left_joins = [(
'(Issue2ApprovalValue AS {alias} JOIN IssuePhaseDef AS {phase_alias} '
'ON {alias}.phase_id = {phase_alias}.id AND {name_cond}) '
'ON Issue.id = {alias}.issue_id'.format(
alias=alias, phase_alias=phase_alias, name_cond=cond_str),
cond_args)]
where = [_CompareAlreadyJoined(phase_alias, cond.op, 'name')]
return left_joins, where, []
_PROCESSORS = {
'owner': _ProcessOwnerCond,
'owner_id': _ProcessOwnerIDCond,
'ownerlastvisit': _ProcessOwnerLastVisitCond,
'ownerbouncing': _ProcessIsOwnerBouncing,
'reporter': _ProcessReporterCond,
'reporter_id': _ProcessReporterIDCond,
'cc': _ProcessCcCond,
'cc_id': _ProcessCcIDCond,
'starredby': _ProcessStarredByCond,
'starredby_id': _ProcessStarredByIDCond,
'commentby': _ProcessCommentByCond,
'commentby_id': _ProcessCommentByIDCond,
'status_id': _ProcessStatusIDCond,
'summary': _ProcessSummaryCond,
'label_id': _ProcessLabelIDCond,
'component_id': _ProcessComponentIDCond,
'blockedon_id': _ProcessBlockedOnIDCond,
'blocking_id': _ProcessBlockingIDCond,
'mergedinto_id': _ProcessMergedIntoIDCond,
'attachment': _ProcessAttachmentCond,
'hotlist_id': _ProcessHotlistIDCond,
'hotlist': _ProcessHotlistCond,
}
def _ProcessCond(cond_num, cond, snapshot_mode):
"""Translate one term of the user's search into an SQL query.
Args:
cond_num: integer cond number used to make distinct local variable names.
cond: user query cond parsed by query2ast.py.
Returns:
A pair of lists (left_joins, where) to use when building the SQL SELECT
statement. Each of them is a list of (str, [val, ...]) pairs.
"""
alias = 'Cond%d' % cond_num
spare_alias = 'Spare%d' % cond_num
# Note: a condition like [x=y] has field_name "x", there may be multiple
# field definitions that match "x", but they will all have field_name "x".
field_def = cond.field_defs[0]
assert all(field_def.field_name == fd.field_name for fd in cond.field_defs)
if field_def.field_name in NATIVE_SEARCHABLE_FIELDS:
# TODO(jeffcarp): Support local_id search here.
if snapshot_mode:
return [], [], [cond]
else:
col = NATIVE_SEARCHABLE_FIELDS[field_def.field_name]
where = [_Compare(
'Issue', cond.op, field_def.field_type, col,
cond.str_values or cond.int_values)]
return [], where, []
elif field_def.field_name in _PROCESSORS:
proc = _PROCESSORS[field_def.field_name]
return proc(cond, alias, spare_alias, snapshot_mode)
# Any phase conditions use the sql.SHORTHAND['phase_cond'], which expects a
# 'Phase' alias. 'phase_cond' cannot expect a 'Spare' alias because
# _ProcessCustomFieldCond also creates a phase_cond string where it uses the
# 'Phase' alias because it needs the 'Spare' alias for other conditions.
elif field_def.field_name == 'gate':
phase_alias = 'Phase%d' % cond_num
return _ProcessPhaseCond(cond, alias, phase_alias, snapshot_mode)
elif field_def.field_id: # it is a search on a custom field
phase_alias = 'Phase%d' % cond_num
if field_def.field_type == tracker_pb2.FieldTypes.APPROVAL_TYPE:
return _ProcessApprovalFieldCond(cond, alias, spare_alias, snapshot_mode)
return _ProcessCustomFieldCond(
cond, alias, spare_alias, phase_alias, snapshot_mode)
elif (cond.op in (ast_pb2.QueryOp.TEXT_HAS, ast_pb2.QueryOp.NOT_TEXT_HAS) and
(field_def.field_name in tracker_fulltext.ISSUE_FULLTEXT_FIELDS or
field_def.field_name == 'any_field')):
if snapshot_mode:
return [], [], [cond]
# This case handled by full-text search.
else:
logging.error('untranslated search cond %r', cond)
return [], [], []
def _Compare(alias, op, val_type, col, vals):
"""Return an SQL comparison for the given values. For use in WHERE or ON.
Args:
alias: String name of the table or alias defined in a JOIN clause.
op: One of the operators defined in ast_pb2.py.
val_type: One of the value types defined in ast_pb2.py.
col: string column name to compare to vals.
vals: list of values that the user is searching for.
Returns:
(cond_str, cond_args) where cond_str is a SQL condition that may contain
some %s placeholders, and cond_args is the list of values that fill those
placeholders. If the condition string contains any AND or OR operators,
the whole expression is put inside parens.
Raises:
NoPossibleResults: The user's query is impossible to ever satisfy, e.g.,
it requires matching an empty set of labels.
"""
vals_ph = sql.PlaceHolders(vals)
if col in ['label', 'status', 'email', 'name']:
alias_col = 'LOWER(%s.%s)' % (alias, col)
else:
alias_col = '%s.%s' % (alias, col)
def Fmt(cond_str):
return cond_str.format(alias_col=alias_col, vals_ph=vals_ph)
no_value = (0 if val_type in [tracker_pb2.FieldTypes.DATE_TYPE,
tracker_pb2.FieldTypes.INT_TYPE] else '')
if op == ast_pb2.QueryOp.IS_DEFINED:
return Fmt('({alias_col} IS NOT NULL AND {alias_col} != %s)'), [no_value]
if op == ast_pb2.QueryOp.IS_NOT_DEFINED:
return Fmt('({alias_col} IS NULL OR {alias_col} = %s)'), [no_value]
if val_type in [tracker_pb2.FieldTypes.DATE_TYPE,
tracker_pb2.FieldTypes.INT_TYPE]:
if op == ast_pb2.QueryOp.TEXT_HAS:
op = ast_pb2.QueryOp.EQ
if op == ast_pb2.QueryOp.NOT_TEXT_HAS:
op = ast_pb2.QueryOp.NE
if op == ast_pb2.QueryOp.EQ:
if not vals:
raise NoPossibleResults('Column %s has no possible value' % alias_col)
elif len(vals) == 1:
cond_str = Fmt('{alias_col} = %s')
else:
cond_str = Fmt('{alias_col} IN ({vals_ph})')
return cond_str, vals
if op == ast_pb2.QueryOp.NE:
if not vals:
return 'TRUE', [] # a no-op that matches every row.
elif len(vals) == 1:
comp = Fmt('{alias_col} != %s')
else:
comp = Fmt('{alias_col} NOT IN ({vals_ph})')
return '(%s IS NULL OR %s)' % (alias_col, comp), vals
wild_vals = ['%%%s%%' % val for val in vals]
if op == ast_pb2.QueryOp.TEXT_HAS:
cond_str = ' OR '.join(Fmt('{alias_col} LIKE %s') for v in vals)
return ('(%s)' % cond_str), wild_vals
if op == ast_pb2.QueryOp.NOT_TEXT_HAS:
cond_str = (Fmt('{alias_col} IS NULL OR ') +
' AND '.join(Fmt('{alias_col} NOT LIKE %s') for v in vals))
return ('(%s)' % cond_str), wild_vals
# Note: These operators do not support quick-OR
val = vals[0]
if op == ast_pb2.QueryOp.GT:
return Fmt('{alias_col} > %s'), [val]
if op == ast_pb2.QueryOp.LT:
return Fmt('{alias_col} < %s'), [val]
if op == ast_pb2.QueryOp.GE:
return Fmt('{alias_col} >= %s'), [val]
if op == ast_pb2.QueryOp.LE:
return Fmt('{alias_col} <= %s'), [val]
logging.error('unknown op: %r', op)
def _CompareAlreadyJoined(alias, op, col):
"""Return a WHERE clause comparison that checks that a join succeeded."""
def Fmt(cond_str):
return cond_str.format(alias_col='%s.%s' % (alias, col))
if op in (ast_pb2.QueryOp.NE, ast_pb2.QueryOp.NOT_TEXT_HAS,
ast_pb2.QueryOp.IS_NOT_DEFINED):
return Fmt('{alias_col} IS NULL'), []
else:
return Fmt('{alias_col} IS NOT NULL'), []
class Error(Exception):
"""Base class for errors from this module."""
class NoPossibleResults(Error):
"""The query could never match any rows from the database, so don't try.."""