<gh_stars>0
# Copyright 2016 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style
# license that can be found in the LICENSE file or at
# https://developers.google.com/open-source/licenses/bsd

"""A set of functions that integrate the GAE search index with Monorail."""
from __future__ import print_function
from __future__ import division
from __future__ import absolute_import

import collections
import datetime
import logging
import re
import time

from google.appengine.api import search

from proto import ast_pb2
from proto import tracker_pb2


# TODO(jrobbins): Consider re-implementing this whole file by using a
# BNF syntax specification and a parser generator or library.

# encodings
UTF8 = 'utf-8'

# Operator used for OR statements.
OR_SYMBOL = ' OR '

# Token types used for parentheses parsing.
SUBQUERY = ast_pb2.TokenType.SUBQUERY
LEFT_PAREN = ast_pb2.TokenType.LEFT_PAREN
RIGHT_PAREN = ast_pb2.TokenType.RIGHT_PAREN
OR = ast_pb2.TokenType.OR

# Field types and operators
BOOL = tracker_pb2.FieldTypes.BOOL_TYPE
DATE = tracker_pb2.FieldTypes.DATE_TYPE
NUM = tracker_pb2.FieldTypes.INT_TYPE
TXT = tracker_pb2.FieldTypes.STR_TYPE
APPROVAL = tracker_pb2.FieldTypes.APPROVAL_TYPE

EQ = ast_pb2.QueryOp.EQ
NE = ast_pb2.QueryOp.NE
LT = ast_pb2.QueryOp.LT
GT = ast_pb2.QueryOp.GT
LE = ast_pb2.QueryOp.LE
GE = ast_pb2.QueryOp.GE
TEXT_HAS = ast_pb2.QueryOp.TEXT_HAS
NOT_TEXT_HAS = ast_pb2.QueryOp.NOT_TEXT_HAS
IS_DEFINED = ast_pb2.QueryOp.IS_DEFINED
IS_NOT_DEFINED = ast_pb2.QueryOp.IS_NOT_DEFINED
KEY_HAS = ast_pb2.QueryOp.KEY_HAS

# Mapping from user query comparison operators to our internal representation.
OPS = {
    ':': TEXT_HAS,
    '=': EQ,
    '!=': NE,
    '<': LT,
    '>': GT,
    '<=': LE,
    '>=': GE,
}

# When the query has a leading minus, switch the operator for its opposite.
NEGATED_OPS = {
    EQ: NE,
    NE: EQ,
    LT: GE,
    GT: LE,
    LE: GT,
    GE: LT,
    TEXT_HAS: NOT_TEXT_HAS,
    # IS_DEFINED is handled separately.
    }

# This is a partial regular expression that matches all of our comparison
# operators, such as =, 1=, >, and <.  Longer ones listed first so that the
# shorter ones don't cause premature matches.
OPS_PATTERN = '|'.join(
    map(re.escape, sorted(list(OPS.keys()), key=lambda op: -len(op))))

# This RE extracts search terms from a subquery string.
TERM_RE = re.compile(
    r'(-?"[^"]+")|'  # E.g., ["division by zero"]
    r'(\S+(%s)[^ "]+)|'  # E.g., [stars>10]
    r'(\w+(%s)"[^"]+")|'  # E.g., [summary:"memory leak"]
    r'(-?[._\*\w][-._\*\w]+)'  # E.g., [-workaround]
    % (OPS_PATTERN, OPS_PATTERN), flags=re.UNICODE)

# This RE is used to further decompose a comparison term into prefix, op, and
# value.  E.g., [stars>10] or [is:open] or [summary:"memory leak"].  The prefix
# can include a leading "-" to negate the comparison.
OP_RE = re.compile(
    r'^(?P<prefix>[-_.\w]*?)'
    r'(?P<op>%s)'
    r'(?P<value>([-@\w][-\*,./:<=>@\w]*|"[^"]*"))$' %
    OPS_PATTERN,
    flags=re.UNICODE)


# Predefined issue fields passed to the query parser.
_ISSUE_FIELDS_LIST = [
    (ast_pb2.ANY_FIELD, TXT),
    ('attachment', TXT),  # attachment file names
    ('attachments', NUM),  # number of attachment files
    ('blocked', BOOL),
    ('blockedon', TXT),
    ('blockedon_id', NUM),
    ('blocking', TXT),
    ('blocking_id', NUM),
    ('cc', TXT),
    ('cc_id', NUM),
    ('comment', TXT),
    ('commentby', TXT),
    ('commentby_id', NUM),
    ('component', TXT),
    ('component_id', NUM),
    ('description', TXT),
    ('gate', TXT),
    ('hotlist', TXT),
    ('hotlist_id', NUM),
    ('id', NUM),
    ('is_spam', BOOL),
    ('label', TXT),
    ('label_id', NUM),
    ('mergedinto', NUM),
    ('mergedinto_id', NUM),
    ('open', BOOL),
    ('owner', TXT),
    ('ownerbouncing', BOOL),
    ('owner_id', NUM),
    ('project', TXT),
    ('reporter', TXT),
    ('reporter_id', NUM),
    ('spam', BOOL),
    ('stars', NUM),
    ('starredby', TXT),
    ('starredby_id', NUM),
    ('status', TXT),
    ('status_id', NUM),
    ('summary', TXT),
    ]

_DATE_FIELDS = (
    'closed',
    'modified',
    'opened',
    'ownermodified',
    'ownerlastvisit',
    'statusmodified',
    'componentmodified',
    )

# Add all _DATE_FIELDS to _ISSUE_FIELDS_LIST.
_ISSUE_FIELDS_LIST.extend((date_field, DATE) for date_field in _DATE_FIELDS)

_DATE_FIELD_SUFFIX_TO_OP = {
    '-after': '>',
    '-before': '<',
}

SET_BY_SUFFIX = '-by'
SET_ON_SUFFIX = '-on'
APPROVER_SUFFIX = '-approver'
STATUS_SUFFIX = '-status'

_APPROVAL_SUFFIXES = (
    SET_BY_SUFFIX,
    SET_ON_SUFFIX,
    APPROVER_SUFFIX,
    STATUS_SUFFIX,
)

BUILTIN_ISSUE_FIELDS = {
    f_name: tracker_pb2.FieldDef(field_name=f_name, field_type=f_type)
    for f_name, f_type in _ISSUE_FIELDS_LIST}


# Do not treat strings that start with the below as key:value search terms.
# See bugs.chromium.org/p/monorail/issues/detail?id=419 for more detail.
NON_OP_PREFIXES = (
    'http:',
    'https:',
)


def ParseUserQuery(
    query, scope, builtin_fields, harmonized_config, warnings=None,
    now=None):
  # type: (str, str, Mapping[str, proto.tracker_pb2.FieldDef],
  #   proto.tracker_pb2.ProjectIssueConfig, Sequence[str], int) ->
  #     proto.ast_pb2.QueryAST
  """Parse a user query and return a set of structure terms.

  Args:
    query: string with user's query.  E.g., 'Priority=High'.
    scope: string search terms that define the scope in which the
        query should be executed.  They are expressed in the same
        user query language.  E.g., adding the canned query.
    builtin_fields: dict {field_name: FieldDef(field_name, type)}
        mapping field names to FieldDef objects for built-in fields.
    harmonized_config: config for all the projects being searched.
        @@@ custom field name is not unique in cross project search.
         - custom_fields = {field_name: [fd, ...]}
         - query build needs to OR each possible interpretation
         - could be label in one project and field in another project.
        @@@ what about searching across all projects?
    warnings: optional list to accumulate warning messages.
    now: optional timestamp for tests, otherwise time.time() is used.

  Returns:
    A QueryAST with conjunctions (usually just one), where each has a list of
    Condition PBs with op, fields, str_values and int_values.  E.g., the query
    [priority=high leak OR stars>100] over open issues would return
    QueryAST(
      Conjunction(Condition(EQ, [open_fd], [], [1]),
                  Condition(EQ, [label_fd], ['priority-high'], []),
                  Condition(TEXT_HAS, any_field_fd, ['leak'], [])),
      Conjunction(Condition(EQ, [open_fd], [], [1]),
                  Condition(GT, [stars_fd], [], [100])))

  Raises:
    InvalidQueryError: If a problem was detected in the user's query.
  """
  if warnings is None:
    warnings = []

  # Convert the overall query into one or more OR'd subqueries.
  subqueries = QueryToSubqueries(query)

  # Make a dictionary of all fields: built-in + custom in each project.
  combined_fields = collections.defaultdict(
      list, {field_name: [field_def]
             for field_name, field_def in builtin_fields.items()})
  for fd in harmonized_config.field_defs:
    if fd.field_type != tracker_pb2.FieldTypes.ENUM_TYPE:
      # Only do non-enum fields because enums are stored as labels
      combined_fields[fd.field_name.lower()].append(fd)
      if fd.field_type == APPROVAL:
        for approval_suffix in _APPROVAL_SUFFIXES:
          combined_fields[fd.field_name.lower() + approval_suffix].append(fd)

  conjunctions = [
      _ParseConjunction(sq, scope, combined_fields, warnings, now=now)
      for sq in subqueries]
  return ast_pb2.QueryAST(conjunctions=conjunctions)


def _ParseConjunction(subquery, scope, fields, warnings, now=None):
  # type: (str, str, Mapping[str, proto.tracker_pb2.FieldDef], Sequence[str],
  #     int) -> proto.ast_pb2.Condition
  """Parse part of a user query into a Conjunction PB."""
  scoped_query = ('%s %s' % (scope, subquery)).lower()
  cond_strs = _ExtractConds(scoped_query, warnings)
  conds = [_ParseCond(cond_str, fields, warnings, now=now)
           for cond_str in cond_strs]
  conds = [cond for cond in conds if cond]
  return ast_pb2.Conjunction(conds=conds)


def _ParseCond(cond_str, fields, warnings, now=None):
  # type: (str, Mapping[str, proto.tracker_pb2.FieldDef], Sequence[str],
  #     int) -> proto.ast_pb2.Condition
  """Parse one user query condition string into a Condition PB."""
  op_match = OP_RE.match(cond_str)
  # Do not treat as key:value search terms if any of the special prefixes match.
  special_prefixes_match = any(
      cond_str.startswith(p) for p in NON_OP_PREFIXES)
  if op_match and not special_prefixes_match:
    prefix = op_match.group('prefix')
    op = op_match.group('op')
    val = op_match.group('value')
    # Special case handling to continue to support old date query terms from
    # code.google.com. See monorail:151 for more details.
    if prefix.startswith(_DATE_FIELDS):
      for date_suffix in _DATE_FIELD_SUFFIX_TO_OP:
        if prefix.endswith(date_suffix):
          prefix = prefix.rstrip(date_suffix)
          op = _DATE_FIELD_SUFFIX_TO_OP[date_suffix]
    return _ParseStructuredTerm(prefix, op, val, fields, now=now)

  # Treat the cond as a full-text search term, which might be negated.
  if cond_str.startswith('-'):
    op = NOT_TEXT_HAS
    cond_str = cond_str[1:]
  else:
    op = TEXT_HAS

  # Construct a full-text Query object as a dry-run to validate that
  # the syntax is acceptable.
  try:
    _fts_query = search.Query(cond_str)
  except search.QueryError:
    warnings.append('Ignoring full-text term: %s' % cond_str)
    return None

  # Flag a potential user misunderstanding.
  if cond_str.lower() in ('and', 'or', 'not'):
    warnings.append(
        'The only supported boolean operator is OR (all capitals).')

  return ast_pb2.MakeCond(
      op, [BUILTIN_ISSUE_FIELDS[ast_pb2.ANY_FIELD]], [cond_str], [])


def _ParseStructuredTerm(prefix, op_str, value, fields, now=None):
  # type: (str, str, str, Mapping[str, proto.tracker_pb2.FieldDef]) ->
  #     proto.ast_pb2.Condition
  """Parse one user structured query term into an internal representation.

  Args:
    prefix: The query operator, usually a field name.  E.g., summary. It can
      also be special operators like "is" to test boolean fields.
    op_str: the comparison operator.  Usually ":" or "=", but can be any OPS.
    value: the value to compare against, e.g., term to find in that field.
    fields: dict {name_lower: [FieldDef, ...]} for built-in and custom fields.
    now: optional timestamp for tests, otherwise time.time() is used.

  Returns:
    A Condition PB.
  """
  unquoted_value = value.strip('"')
  # Quick-OR is a convenient way to write one condition that matches any one of
  # multiple values, like set membership.  E.g., [Priority=High,Critical].
  # Ignore empty values caused by duplicated or trailing commas. E.g.,
  # [Priority=High,,Critical,] is equivalent to [Priority=High,Critical].
  quick_or_vals = [v.strip() for v in unquoted_value.split(',') if v.strip()]

  op = OPS[op_str]
  negate = False
  if prefix.startswith('-'):
    negate = True
    op = NEGATED_OPS.get(op, op)
    prefix = prefix[1:]

  if prefix == 'is' and unquoted_value in [
      'open', 'blocked', 'spam', 'ownerbouncing']:
    return ast_pb2.MakeCond(
        NE if negate else EQ, fields[unquoted_value], [], [])

  # Search entries with or without any value in the specified field.
  if prefix == 'has':
    op = IS_NOT_DEFINED if negate else IS_DEFINED
    if '.' in unquoted_value:  # Possible search for phase field with any value.
      phase_name, possible_field = unquoted_value.split('.', 1)
      if possible_field in fields:
        return ast_pb2.MakeCond(
            op, fields[possible_field], [], [], phase_name=phase_name)
    elif unquoted_value in fields:  # Look for that field with any value.
      return ast_pb2.MakeCond(op, fields[unquoted_value], [], [])
    else:  # Look for any label with that prefix.
      return ast_pb2.MakeCond(op, fields['label'], [unquoted_value], [])

  # Search entries with certain gates.
  if prefix == 'gate':
    return ast_pb2.MakeCond(op, fields['gate'], quick_or_vals, [])

  # Determine hotlist query type.
  # If prefix is not 'hotlist', quick_or_vals is empty, or qov
  # does not contain ':', is_fields will remain True
  is_fields = True
  if prefix == 'hotlist':
    try:
      if ':' not in quick_or_vals[0]:
        is_fields = False
    except IndexError:
      is_fields = False

  phase_name = None
  if '.' in prefix and is_fields:
    split_prefix = prefix.split('.', 1)
    if split_prefix[1] in fields:
      phase_name, prefix = split_prefix

  # search built-in and custom fields. E.g., summary.
  if prefix in fields and is_fields:
    # Note: if first matching field is date-type, we assume they all are.
    # TODO(jrobbins): better handling for rare case where multiple projects
    # define the same custom field name, and one is a date and another is not.
    first_field = fields[prefix][0]
    if first_field.field_type == DATE:
      date_values = [_ParseDateValue(val, now=now) for val in quick_or_vals]
      return ast_pb2.MakeCond(op, fields[prefix], [], date_values)
    elif first_field.field_type == APPROVAL and prefix.endswith(SET_ON_SUFFIX):
      date_values = [_ParseDateValue(val, now=now) for val in quick_or_vals]
      return ast_pb2.MakeCond(
          op,
          fields[prefix], [],
          date_values,
          key_suffix=SET_ON_SUFFIX,
          phase_name=phase_name)
    else:
      quick_or_ints = []
      for qov in quick_or_vals:
        try:
          quick_or_ints.append(int(qov))
        except ValueError:
          pass
      if first_field.field_type == APPROVAL:
        for approval_suffix in _APPROVAL_SUFFIXES:
          if prefix.endswith(approval_suffix):
            return ast_pb2.MakeCond(op, fields[prefix], quick_or_vals,
                                    quick_or_ints, key_suffix=approval_suffix,
                                    phase_name=phase_name)
      return ast_pb2.MakeCond(op, fields[prefix], quick_or_vals,
                              quick_or_ints, phase_name=phase_name)

  # Since it is not a field, treat it as labels, E.g., Priority.
  quick_or_labels = ['%s-%s' % (prefix, v) for v in quick_or_vals]
  # Convert substring match to key-value match if user typed 'foo:bar'.
  if op == TEXT_HAS:
    op = KEY_HAS
  return ast_pb2.MakeCond(op, fields['label'], quick_or_labels, [])


def _ExtractConds(query, warnings):
  # type: (str, Sequence[str]) -> Sequence[str]
  """Parse a query string into a list of individual condition strings.

  Args:
    query: UTF-8 encoded search query string.
    warnings: list to accumulate warning messages.

  Returns:
    A list of query condition strings.
  """
  # Convert to unicode then search for distinct terms.
  term_matches = TERM_RE.findall(query)

  terms = []
  for (phrase, word_label, _op1, phrase_label, _op2,
       word) in term_matches:
    # Case 1: Quoted phrases, e.g., ["hot dog"].
    if phrase_label or phrase:
      terms.append(phrase_label or phrase)

    # Case 2: Comparisons
    elif word_label:
      special_prefixes_match = any(
          word_label.startswith(p) for p in NON_OP_PREFIXES)
      match = OP_RE.match(word_label)
      if match and not special_prefixes_match:
        label = match.group('prefix')
        op = match.group('op')
        word = match.group('value')
        terms.append('%s%s"%s"' % (label, op, word))
      else:
        # It looked like a key:value cond, but not exactly, so treat it
        # as fulltext search.  It is probably a tiny bit of source code.
        terms.append('"%s"' % word_label)

    # Case 3: Simple words.
    elif word:
      terms.append(word)

    else:  # pragma: no coverage
      warnings.append('Unparsable search term')

  return terms


def _ParseDateValue(val, now=None):
  # type: (str, int) -> int
  """Convert the user-entered date into timestamp."""
  # Support timestamp value such as opened>1437671476
  try:
    return int(val)
  except ValueError:
    pass

  # TODO(jrobbins): future: take timezones into account.
  # TODO(jrobbins): for now, explain to users that "today" is
  # actually now: the current time, not 12:01am in their timezone.
  # In fact, it is not very useful because everything in the system
  # happened before the current time.
  if val == 'today':
    return _CalculatePastDate(0, now=now)
  elif val.startswith('today-'):
    try:
      days_ago = int(val.split('-')[1])
    except ValueError:
      raise InvalidQueryError('Could not parse date: ' + val)
    return _CalculatePastDate(days_ago, now=now)

  try:
    if '/' in val:
      year, month, day = [int(x) for x in val.split('/')]
    elif '-' in val:
      year, month, day = [int(x) for x in val.split('-')]
    else:
      raise InvalidQueryError('Could not parse date: ' + val)
  except ValueError:
    raise InvalidQueryError('Could not parse date: ' + val)

  try:
    return int(time.mktime(datetime.datetime(year, month, day).timetuple()))
  except ValueError:
    raise InvalidQueryError('Could not parse date: ' + val)


def _CalculatePastDate(days_ago, now=None):
  # type: (int, int) -> int
  """Calculates the timestamp N days ago from now."""
  if now is None:
    now = int(time.time())
  ts = now - days_ago * 24 * 60 * 60
  return ts


def QueryToSubqueries(query):
  # type (str) -> Sequence[str]
  """Splits a query into smaller queries based on Monorail's search syntax.

  This function handles parsing parentheses and OR statements in Monorail's
  search syntax. By doing this parsing for OR statements and parentheses up
  front in FrontendSearchPipeline, we are able to convert complex queries
  with lots of ORs into smaller, more easily cacheable query terms.

  These outputted subqueries should collectively return the same query results
  as the initial input query without containing any ORs or parentheses,
  allowing later search layers to parse queries without worrying about ORs
  or parentheses.

  Some examples of possible queries and their expected output:

  - '(A OR B) (C OR D) OR (E OR F)' -> ['A C', 'A D', 'B C', 'B D', 'E', 'F']
  - '(A) OR (B)' -> ['A', 'B']
  - '(A ((C) OR (D OR (E OR F))))' -> ['A C', 'A D', 'A E', 'A F']

  Where A, B, C, D, etc could be any list of conjunctions. ie: "owner:me",
  "Pri=1", "hello world Hotlist=test", "label!=a11y", etc

  Note: Monorail implicitly ANDs any query terms separated by a space. For
  the most part, AND functionality is handled at a later layer in search
  processing. However, this case becomes important here when considering the
  fact that a prentheses group can either be ANDed or ORed with terms that
  surround it.

  The _MultiplySubqueries helper is used to AND the results of different
  groups together whereas concatenating lists is used to OR subqueries
  together.

  Args:
    query: The initial query that was sent to the search.

  Returns:
    List of query fragments to be independently processed as search terms.

  Raises:
    InvalidQueryError if parentheses are unmatched.
  """
  tokens = _ValidateAndTokenizeQuery(query)

  # Using an iterator allows us to keep our current loop position across
  # helpers. This makes recursion a lot easier.
  token_iterator = PeekIterator(tokens)

  subqueries = _ParseQuery(token_iterator)

  if not len(subqueries):
    # Several cases, such as an empty query or a query with only parentheses
    # will result in an empty set of subqueries. In these cases, we still want
    # to give the search pipeline a single empty query to process.
    return ['']

  return subqueries


def _ParseQuery(token_iterator):
  # type (Sequence[proto.ast_pb2.QueryToken]) -> Sequence[str]
  """Recursive helper to convert query tokens into a list of subqueries.

  Parses a Query based on the following grammar (EBNF):

    Query             := OrGroup { [OrOperator] OrGroup }
    OrGroup           := AndGroup { AndGroup }
    AndGroup          := Subquery | ParenthesesGroup
    ParenthesesGroup  := "(" Query ")"
    Subquery          := /.+/
    OrOperator        := " OR "

  An important nuance is that two groups can be next to each other, separated
  only by a word boundary (ie: space or parentheses). In this case, they are
  implicitly ANDed. In practice, because unparenthesized fragments ANDed by
  spaces are stored as single tokens, we only need to handle the AND case when
  a parentheses group is implicitly ANDed with an adjacent group.

  Order of precedence is implemented by recursing through OR groups before
  recursing through AND groups.

  Args:
    token_iterator: Iterator over a list of query tokens.

  Returns:
    List of query fragments to be processed as search terms.

  Raises:
    InvalidQueryError if tokens were inputted in a format that does not follow
    our search grammar.
  """
  subqueries = []
  try:
    if token_iterator.peek().token_type == OR:
      # Edge case: Ignore empty OR groups at the starte of a ParenthesesGroup.
      # ie: "(OR A)" will be processed as "A"
      next(token_iterator)

    subqueries = _ParseOrGroup(token_iterator)

    while token_iterator.peek().token_type == OR:
      # Consume the OR tokens without doing anything with it.
      next(token_iterator)

      next_token = token_iterator.peek()
      if next_token.token_type == RIGHT_PAREN:
        # Edge case: Ignore empty OR groups at the end of a ParenthesesGroup.
        # ie: "(A OR)" will be processed as "A"
        return subqueries

      next_subqueries = _ParseOrGroup(token_iterator)

      # Concatenate results of OR groups together.
      subqueries = subqueries + next_subqueries

  except StopIteration:
    pass
  # Return when we've reached the end of the string.
  return subqueries


def _ParseOrGroup(token_iterator):
  # type (Sequence[proto.ast_pb2.QueryToken]) -> Sequence[str]
  """Recursive helper to convert a single "OrGroup" into subqueries.

  An OrGroup here is based on the following grammar:

    Query             := OrGroup { [OrOperator] OrGroup }
    OrGroup           := AndGroup { AndGroup }
    AndGroup          := Subquery | ParenthesesGroup
    ParenthesesGroup  := "(" Query ")"
    Subquery          := /.+/
    OrOperator        := " OR "

  Args:
    token_iterator: Iterator over a list of query tokens.

  Returns:
    List of query fragments to be processed as search terms.

  Raises:
    InvalidQueryError if tokens were inputted in a format that does not follow
    our search grammar.
  """
  subqueries = _ParseAndGroup(token_iterator)

  try:
    # Iterate until there are no more AND groups left to see.
    # Subquery or left parentheses are the possible starts of an AndGroup.
    while (token_iterator.peek().token_type == SUBQUERY or
           token_iterator.peek().token_type == LEFT_PAREN):

      # Find subqueries from the next AND group.
      next_subqueries = _ParseAndGroup(token_iterator)

      # Multiply all results across AND groups together.
      subqueries = _MultiplySubqueries(subqueries, next_subqueries)
  except StopIteration:
    pass

  return subqueries


def _ParseAndGroup(token_iterator):
  # type (Sequence[proto.ast_pb2.QueryToken]) -> Sequence[str]
  """Recursive helper to convert a single "AndGroup" into subqueries.

  An OrGroup here is based on the following grammar:

    Query             := OrGroup { [OrOperator] OrGroup }
    OrGroup           := AndGroup { AndGroup }
    AndGroup          := Subquery | ParenthesesGroup
    ParenthesesGroup  := "(" Query ")"
    Subquery          := /.+/
    OrOperator        := " OR "

  Args:
    token_iterator: Iterator over a list of query tokens.

  Returns:
    List of query fragments to be processed as search terms.

  Raises:
    InvalidQueryError if tokens were inputted in a format that does not follow
    our search grammar.
  """
  try:
    token = next(token_iterator)
    if token.token_type == LEFT_PAREN:
      if token_iterator.peek().token_type == RIGHT_PAREN:
        # Don't recurse into the ParenthesesGroup if there's nothing inside.
        next(token_iterator)
        return []

      # Recurse into the ParenthesesGroup.
      subqueries = _ParseQuery(token_iterator)

      # Next token should be a right parenthesis.
      next(token_iterator)

      return subqueries
    elif token.token_type == SUBQUERY:
      return [token.value]
    else:
      # This should not happen if other QueryToSubqueries helpers are working
      # properly.
      raise InvalidQueryError('Inputted tokens do not follow grammar.')
  except StopIteration:
    pass
  return []


def _ValidateAndTokenizeQuery(query):
  # type: (str) -> Sequence[proto.ast_pb2.QueryToken]
  """Converts the input query into a set of tokens for easier parsing.

  Tokenizing the query string before parsing allows us to not have to as many
  string manipulations while parsing, which simplifies our later code.

  Args:
    query: Query to tokenize.

  Returns:
    List of Token objects for use in query processing.

  Raises:
    InvalidQueryError if parentheses are unmatched.
  """
  tokens = []  # Function result
  count = 0  # Used for checking if parentheses are balanced
  s = ''  # Records current string fragment. Cleared when a token is added.

  for ch in query:
    if ch == '(':
      count += 1

      # Add subquery from before we hit this parenthesis.
      tokens.extend(_TokenizeSubqueryOnOr(s))
      s = ''

      tokens.append(ast_pb2.QueryToken(token_type=LEFT_PAREN))
    elif ch == ')':
      count -= 1

      if count < 0:
        # More closing parentheses then open parentheses.
        raise InvalidQueryError('Search query has unbalanced parentheses.')

      # Add subquery from before we hit this parenthesis.
      tokens.extend(_TokenizeSubqueryOnOr(s))
      s = ''

      tokens.append(ast_pb2.QueryToken(token_type=RIGHT_PAREN))
    else:
      s += ch

  if count != 0:
    raise InvalidQueryError('Search query has unbalanced parentheses.')

  # Add any trailing tokens.
  tokens.extend(_TokenizeSubqueryOnOr(s))

  return tokens


def _TokenizeSubqueryOnOr(subquery):
  # type: (str) -> Sequence[proto.ast_pb2.QueryToken]
  """Helper to split a subquery by OR and convert the result into tokens.

  Args:
    subquery: A string without parentheses to tokenize.

  Returns:
    Tokens for the subquery with OR tokens separating query strings if
    applicable.
  """
  if len(subquery) == 0:
    return []

  result = []
  fragments = subquery.split(OR_SYMBOL)
  for f in fragments:
    # Interleave the string fragments with OR tokens.
    result.append(ast_pb2.QueryToken(token_type=SUBQUERY, value=f.strip()))
    result.append(ast_pb2.QueryToken(token_type=OR))

  # Remove trailing OR.
  result.pop()

  # Trim empty strings at the beginning or end. ie: if subquery is ' OR ',
  # we want the list to be ['OR'], not ['', 'OR', ''].
  if len(result) > 1 and result[0].value == '':
    result.pop(0)
  if len(result) > 1 and result[-1].value == '':
    result.pop()
  return result


def _MultiplySubqueries(a, b):
  # type: (Sequence[str], Sequence[str]) -> Sequence[str]
  """Helper to AND subqueries from two separate lists.

  Args:
    a: First list of subqueries.
    b: Second list of subqueries.

  Returns:
    List with n x m subqueries.
  """
  if not len(a):
    return b
  if not len(b):
    return a
  res = []
  for q1 in a:
    for q2 in b:
      # AND two subqueries together by concatenating them.
      query = (q1.strip() + ' ' + q2.strip()).strip()
      res.append(query)
  return res


class PeekIterator:
  """Simple iterator with peek() functionality.

  Used by QueryToSubqueries to maintain state easily across recursive calls.
  """

  def __init__(self, source):
    # type: (Sequence[Any])
    self.__source = source
    self.__i = 0

  def peek(self):
    # type: () -> Any
    """Gets the next value in the iterator without side effects.

    Returns:
      Next value in iterator.

    Raises:
      StopIteration if you're at the end of the iterator.
    """
    if self.__i >= len(self.__source):
      raise StopIteration
    return self.__source[self.__i]

  def __iter__(self):
    # type: () -> Sequence[Any]
    """Return self to make iterator iterable."""
    return self

  def __repr__(self):
    # type: () -> str
    """Allow logging current iterator value for debugging."""
    try:
      return str(self.peek())
    except StopIteration:
      pass
    return 'End of PeekIterator'

  def next(self):
    # type: () -> Any
    """Gets the next value in the iterator and increments pointer.

    Returns:
      Next value in iterator.

    Raises:
      StopIteration if you're at the end of the iterator.
    """
    if self.__i >= len(self.__source):
      raise StopIteration
    value = self.__source[self.__i]
    self.__i += 1
    return value


class Error(Exception):
  """Base exception class for this package."""
  pass


class InvalidQueryError(Error):
  """Error raised when an invalid query is requested."""
  pass

# Copyright 2016 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style
# license that can be found in the LICENSE file or at
# https://developers.google.com/open-source/licenses/bsd

"""A set of functions that integrate the GAE search index with Monorail."""
from __future__ import print_function
from __future__ import division
from __future__ import absolute_import

import collections
import datetime
import logging
import re
import time

from google.appengine.api import search

from proto import ast_pb2
from proto import tracker_pb2


# TODO(jrobbins): Consider re-implementing this whole file by using a
# BNF syntax specification and a parser generator or library.

# encodings
UTF8 = 'utf-8'

# Operator used for OR statements.
OR_SYMBOL = ' OR '

# Token types used for parentheses parsing.
SUBQUERY = ast_pb2.TokenType.SUBQUERY
LEFT_PAREN = ast_pb2.TokenType.LEFT_PAREN
RIGHT_PAREN = ast_pb2.TokenType.RIGHT_PAREN
OR = ast_pb2.TokenType.OR

# Field types and operators
BOOL = tracker_pb2.FieldTypes.BOOL_TYPE
DATE = tracker_pb2.FieldTypes.DATE_TYPE
NUM = tracker_pb2.FieldTypes.INT_TYPE
TXT = tracker_pb2.FieldTypes.STR_TYPE
APPROVAL = tracker_pb2.FieldTypes.APPROVAL_TYPE

EQ = ast_pb2.QueryOp.EQ
NE = ast_pb2.QueryOp.NE
LT = ast_pb2.QueryOp.LT
GT = ast_pb2.QueryOp.GT
LE = ast_pb2.QueryOp.LE
GE = ast_pb2.QueryOp.GE
TEXT_HAS = ast_pb2.QueryOp.TEXT_HAS
NOT_TEXT_HAS = ast_pb2.QueryOp.NOT_TEXT_HAS
IS_DEFINED = ast_pb2.QueryOp.IS_DEFINED
IS_NOT_DEFINED = ast_pb2.QueryOp.IS_NOT_DEFINED
KEY_HAS = ast_pb2.QueryOp.KEY_HAS

# Mapping from user query comparison operators to our internal representation.
OPS = {
    ':': TEXT_HAS,
    '=': EQ,
    '!=': NE,
    '<': LT,
    '>': GT,
    '<=': LE,
    '>=': GE,
}

# When the query has a leading minus, switch the operator for its opposite.
NEGATED_OPS = {
    EQ: NE,
    NE: EQ,
    LT: GE,
    GT: LE,
    LE: GT,
    GE: LT,
    TEXT_HAS: NOT_TEXT_HAS,
    # IS_DEFINED is handled separately.
    }

# This is a partial regular expression that matches all of our comparison
# operators, such as =, 1=, >, and <.  Longer ones listed first so that the
# shorter ones don't cause premature matches.
OPS_PATTERN = '|'.join(
    map(re.escape, sorted(list(OPS.keys()), key=lambda op: -len(op))))

# This RE extracts search terms from a subquery string.
TERM_RE = re.compile(
    r'(-?"[^"]+")|'  # E.g., ["division by zero"]
    r'(\S+(%s)[^ "]+)|'  # E.g., [stars>10]
    r'(\w+(%s)"[^"]+")|'  # E.g., [summary:"memory leak"]
    r'(-?[._\*\w][-._\*\w]+)'  # E.g., [-workaround]
    % (OPS_PATTERN, OPS_PATTERN), flags=re.UNICODE)

# This RE is used to further decompose a comparison term into prefix, op, and
# value.  E.g., [stars>10] or [is:open] or [summary:"memory leak"].  The prefix
# can include a leading "-" to negate the comparison.
OP_RE = re.compile(
    r'^(?P<prefix>[-_.\w]*?)'
    r'(?P<op>%s)'
    r'(?P<value>([-@\w][-\*,./:<=>@\w]*|"[^"]*"))$' %
    OPS_PATTERN,
    flags=re.UNICODE)


# Predefined issue fields passed to the query parser.
_ISSUE_FIELDS_LIST = [
    (ast_pb2.ANY_FIELD, TXT),
    ('attachment', TXT),  # attachment file names
    ('attachments', NUM),  # number of attachment files
    ('blocked', BOOL),
    ('blockedon', TXT),
    ('blockedon_id', NUM),
    ('blocking', TXT),
    ('blocking_id', NUM),
    ('cc', TXT),
    ('cc_id', NUM),
    ('comment', TXT),
    ('commentby', TXT),
    ('commentby_id', NUM),
    ('component', TXT),
    ('component_id', NUM),
    ('description', TXT),
    ('gate', TXT),
    ('hotlist', TXT),
    ('hotlist_id', NUM),
    ('id', NUM),
    ('is_spam', BOOL),
    ('label', TXT),
    ('label_id', NUM),
    ('mergedinto', NUM),
    ('mergedinto_id', NUM),
    ('open', BOOL),
    ('owner', TXT),
    ('ownerbouncing', BOOL),
    ('owner_id', NUM),
    ('project', TXT),
    ('reporter', TXT),
    ('reporter_id', NUM),
    ('spam', BOOL),
    ('stars', NUM),
    ('starredby', TXT),
    ('starredby_id', NUM),
    ('status', TXT),
    ('status_id', NUM),
    ('summary', TXT),
    ]

_DATE_FIELDS = (
    'closed',
    'modified',
    'opened',
    'ownermodified',
    'ownerlastvisit',
    'statusmodified',
    'componentmodified',
    )

# Add all _DATE_FIELDS to _ISSUE_FIELDS_LIST.
_ISSUE_FIELDS_LIST.extend((date_field, DATE) for date_field in _DATE_FIELDS)

_DATE_FIELD_SUFFIX_TO_OP = {
    '-after': '>',
    '-before': '<',
}

SET_BY_SUFFIX = '-by'
SET_ON_SUFFIX = '-on'
APPROVER_SUFFIX = '-approver'
STATUS_SUFFIX = '-status'

_APPROVAL_SUFFIXES = (
    SET_BY_SUFFIX,
    SET_ON_SUFFIX,
    APPROVER_SUFFIX,
    STATUS_SUFFIX,
)

BUILTIN_ISSUE_FIELDS = {
    f_name: tracker_pb2.FieldDef(field_name=f_name, field_type=f_type)
    for f_name, f_type in _ISSUE_FIELDS_LIST}


# Do not treat strings that start with the below as key:value search terms.
# See bugs.chromium.org/p/monorail/issues/detail?id=419 for more detail.
NON_OP_PREFIXES = (
    'http:',
    'https:',
)


def ParseUserQuery(
    query, scope, builtin_fields, harmonized_config, warnings=None,
    now=None):
  # type: (str, str, Mapping[str, proto.tracker_pb2.FieldDef],
  #   proto.tracker_pb2.ProjectIssueConfig, Sequence[str], int) ->
  #     proto.ast_pb2.QueryAST
  """Parse a user query and return a set of structure terms.

  Args:
    query: string with user's query.  E.g., 'Priority=High'.
    scope: string search terms that define the scope in which the
        query should be executed.  They are expressed in the same
        user query language.  E.g., adding the canned query.
    builtin_fields: dict {field_name: FieldDef(field_name, type)}
        mapping field names to FieldDef objects for built-in fields.
    harmonized_config: config for all the projects being searched.
        @@@ custom field name is not unique in cross project search.
         - custom_fields = {field_name: [fd, ...]}
         - query build needs to OR each possible interpretation
         - could be label in one project and field in another project.
        @@@ what about searching across all projects?
    warnings: optional list to accumulate warning messages.
    now: optional timestamp for tests, otherwise time.time() is used.

  Returns:
    A QueryAST with conjunctions (usually just one), where each has a list of
    Condition PBs with op, fields, str_values and int_values.  E.g., the query
    [priority=high leak OR stars>100] over open issues would return
    QueryAST(
      Conjunction(Condition(EQ, [open_fd], [], [1]),
                  Condition(EQ, [label_fd], ['priority-high'], []),
                  Condition(TEXT_HAS, any_field_fd, ['leak'], [])),
      Conjunction(Condition(EQ, [open_fd], [], [1]),
                  Condition(GT, [stars_fd], [], [100])))

  Raises:
    InvalidQueryError: If a problem was detected in the user's query.
  """
  if warnings is None:
    warnings = []

  # Convert the overall query into one or more OR'd subqueries.
  subqueries = QueryToSubqueries(query)

  # Make a dictionary of all fields: built-in + custom in each project.
  combined_fields = collections.defaultdict(
      list, {field_name: [field_def]
             for field_name, field_def in builtin_fields.items()})
  for fd in harmonized_config.field_defs:
    if fd.field_type != tracker_pb2.FieldTypes.ENUM_TYPE:
      # Only do non-enum fields because enums are stored as labels
      combined_fields[fd.field_name.lower()].append(fd)
      if fd.field_type == APPROVAL:
        for approval_suffix in _APPROVAL_SUFFIXES:
          combined_fields[fd.field_name.lower() + approval_suffix].append(fd)

  conjunctions = [
      _ParseConjunction(sq, scope, combined_fields, warnings, now=now)
      for sq in subqueries]
  return ast_pb2.QueryAST(conjunctions=conjunctions)


def _ParseConjunction(subquery, scope, fields, warnings, now=None):
  # type: (str, str, Mapping[str, proto.tracker_pb2.FieldDef], Sequence[str],
  #     int) -> proto.ast_pb2.Condition
  """Parse part of a user query into a Conjunction PB."""
  scoped_query = ('%s %s' % (scope, subquery)).lower()
  cond_strs = _ExtractConds(scoped_query, warnings)
  conds = [_ParseCond(cond_str, fields, warnings, now=now)
           for cond_str in cond_strs]
  conds = [cond for cond in conds if cond]
  return ast_pb2.Conjunction(conds=conds)


def _ParseCond(cond_str, fields, warnings, now=None):
  # type: (str, Mapping[str, proto.tracker_pb2.FieldDef], Sequence[str],
  #     int) -> proto.ast_pb2.Condition
  """Parse one user query condition string into a Condition PB."""
  op_match = OP_RE.match(cond_str)
  # Do not treat as key:value search terms if any of the special prefixes match.
  special_prefixes_match = any(
      cond_str.startswith(p) for p in NON_OP_PREFIXES)
  if op_match and not special_prefixes_match:
    prefix = op_match.group('prefix')
    op = op_match.group('op')
    val = op_match.group('value')
    # Special case handling to continue to support old date query terms from
    # code.google.com. See monorail:151 for more details.
    if prefix.startswith(_DATE_FIELDS):
      for date_suffix in _DATE_FIELD_SUFFIX_TO_OP:
        if prefix.endswith(date_suffix):
          prefix = prefix.rstrip(date_suffix)
          op = _DATE_FIELD_SUFFIX_TO_OP[date_suffix]
    return _ParseStructuredTerm(prefix, op, val, fields, now=now)

  # Treat the cond as a full-text search term, which might be negated.
  if cond_str.startswith('-'):
    op = NOT_TEXT_HAS
    cond_str = cond_str[1:]
  else:
    op = TEXT_HAS

  # Construct a full-text Query object as a dry-run to validate that
  # the syntax is acceptable.
  try:
    _fts_query = search.Query(cond_str)
  except search.QueryError:
    warnings.append('Ignoring full-text term: %s' % cond_str)
    return None

  # Flag a potential user misunderstanding.
  if cond_str.lower() in ('and', 'or', 'not'):
    warnings.append(
        'The only supported boolean operator is OR (all capitals).')

  return ast_pb2.MakeCond(
      op, [BUILTIN_ISSUE_FIELDS[ast_pb2.ANY_FIELD]], [cond_str], [])


def _ParseStructuredTerm(prefix, op_str, value, fields, now=None):
  # type: (str, str, str, Mapping[str, proto.tracker_pb2.FieldDef]) ->
  #     proto.ast_pb2.Condition
  """Parse one user structured query term into an internal representation.

  Args:
    prefix: The query operator, usually a field name.  E.g., summary. It can
      also be special operators like "is" to test boolean fields.
    op_str: the comparison operator.  Usually ":" or "=", but can be any OPS.
    value: the value to compare against, e.g., term to find in that field.
    fields: dict {name_lower: [FieldDef, ...]} for built-in and custom fields.
    now: optional timestamp for tests, otherwise time.time() is used.

  Returns:
    A Condition PB.
  """
  unquoted_value = value.strip('"')
  # Quick-OR is a convenient way to write one condition that matches any one of
  # multiple values, like set membership.  E.g., [Priority=High,Critical].
  # Ignore empty values caused by duplicated or trailing commas. E.g.,
  # [Priority=High,,Critical,] is equivalent to [Priority=High,Critical].
  quick_or_vals = [v.strip() for v in unquoted_value.split(',') if v.strip()]

  op = OPS[op_str]
  negate = False
  if prefix.startswith('-'):
    negate = True
    op = NEGATED_OPS.get(op, op)
    prefix = prefix[1:]

  if prefix == 'is' and unquoted_value in [
      'open', 'blocked', 'spam', 'ownerbouncing']:
    return ast_pb2.MakeCond(
        NE if negate else EQ, fields[unquoted_value], [], [])

  # Search entries with or without any value in the specified field.
  if prefix == 'has':
    op = IS_NOT_DEFINED if negate else IS_DEFINED
    if '.' in unquoted_value:  # Possible search for phase field with any value.
      phase_name, possible_field = unquoted_value.split('.', 1)
      if possible_field in fields:
        return ast_pb2.MakeCond(
            op, fields[possible_field], [], [], phase_name=phase_name)
    elif unquoted_value in fields:  # Look for that field with any value.
      return ast_pb2.MakeCond(op, fields[unquoted_value], [], [])
    else:  # Look for any label with that prefix.
      return ast_pb2.MakeCond(op, fields['label'], [unquoted_value], [])

  # Search entries with certain gates.
  if prefix == 'gate':
    return ast_pb2.MakeCond(op, fields['gate'], quick_or_vals, [])

  # Determine hotlist query type.
  # If prefix is not 'hotlist', quick_or_vals is empty, or qov
  # does not contain ':', is_fields will remain True
  is_fields = True
  if prefix == 'hotlist':
    try:
      if ':' not in quick_or_vals[0]:
        is_fields = False
    except IndexError:
      is_fields = False

  phase_name = None
  if '.' in prefix and is_fields:
    split_prefix = prefix.split('.', 1)
    if split_prefix[1] in fields:
      phase_name, prefix = split_prefix

  # search built-in and custom fields. E.g., summary.
  if prefix in fields and is_fields:
    # Note: if first matching field is date-type, we assume they all are.
    # TODO(jrobbins): better handling for rare case where multiple projects
    # define the same custom field name, and one is a date and another is not.
    first_field = fields[prefix][0]
    if first_field.field_type == DATE:
      date_values = [_ParseDateValue(val, now=now) for val in quick_or_vals]
      return ast_pb2.MakeCond(op, fields[prefix], [], date_values)
    elif first_field.field_type == APPROVAL and prefix.endswith(SET_ON_SUFFIX):
      date_values = [_ParseDateValue(val, now=now) for val in quick_or_vals]
      return ast_pb2.MakeCond(
          op,
          fields[prefix], [],
          date_values,
          key_suffix=SET_ON_SUFFIX,
          phase_name=phase_name)
    else:
      quick_or_ints = []
      for qov in quick_or_vals:
        try:
          quick_or_ints.append(int(qov))
        except ValueError:
          pass
      if first_field.field_type == APPROVAL:
        for approval_suffix in _APPROVAL_SUFFIXES:
          if prefix.endswith(approval_suffix):
            return ast_pb2.MakeCond(op, fields[prefix], quick_or_vals,
                                    quick_or_ints, key_suffix=approval_suffix,
                                    phase_name=phase_name)
      return ast_pb2.MakeCond(op, fields[prefix], quick_or_vals,
                              quick_or_ints, phase_name=phase_name)

  # Since it is not a field, treat it as labels, E.g., Priority.
  quick_or_labels = ['%s-%s' % (prefix, v) for v in quick_or_vals]
  # Convert substring match to key-value match if user typed 'foo:bar'.
  if op == TEXT_HAS:
    op = KEY_HAS
  return ast_pb2.MakeCond(op, fields['label'], quick_or_labels, [])


def _ExtractConds(query, warnings):
  # type: (str, Sequence[str]) -> Sequence[str]
  """Parse a query string into a list of individual condition strings.

  Args:
    query: UTF-8 encoded search query string.
    warnings: list to accumulate warning messages.

  Returns:
    A list of query condition strings.
  """
  # Convert to unicode then search for distinct terms.
  term_matches = TERM_RE.findall(query)

  terms = []
  for (phrase, word_label, _op1, phrase_label, _op2,
       word) in term_matches:
    # Case 1: Quoted phrases, e.g., ["hot dog"].
    if phrase_label or phrase:
      terms.append(phrase_label or phrase)

    # Case 2: Comparisons
    elif word_label:
      special_prefixes_match = any(
          word_label.startswith(p) for p in NON_OP_PREFIXES)
      match = OP_RE.match(word_label)
      if match and not special_prefixes_match:
        label = match.group('prefix')
        op = match.group('op')
        word = match.group('value')
        terms.append('%s%s"%s"' % (label, op, word))
      else:
        # It looked like a key:value cond, but not exactly, so treat it
        # as fulltext search.  It is probably a tiny bit of source code.
        terms.append('"%s"' % word_label)

    # Case 3: Simple words.
    elif word:
      terms.append(word)

    else:  # pragma: no coverage
      warnings.append('Unparsable search term')

  return terms


def _ParseDateValue(val, now=None):
  # type: (str, int) -> int
  """Convert the user-entered date into timestamp."""
  # Support timestamp value such as opened>1437671476
  try:
    return int(val)
  except ValueError:
    pass

  # TODO(jrobbins): future: take timezones into account.
  # TODO(jrobbins): for now, explain to users that "today" is
  # actually now: the current time, not 12:01am in their timezone.
  # In fact, it is not very useful because everything in the system
  # happened before the current time.
  if val == 'today':
    return _CalculatePastDate(0, now=now)
  elif val.startswith('today-'):
    try:
      days_ago = int(val.split('-')[1])
    except ValueError:
      raise InvalidQueryError('Could not parse date: ' + val)
    return _CalculatePastDate(days_ago, now=now)

  try:
    if '/' in val:
      year, month, day = [int(x) for x in val.split('/')]
    elif '-' in val:
      year, month, day = [int(x) for x in val.split('-')]
    else:
      raise InvalidQueryError('Could not parse date: ' + val)
  except ValueError:
    raise InvalidQueryError('Could not parse date: ' + val)

  try:
    return int(time.mktime(datetime.datetime(year, month, day).timetuple()))
  except ValueError:
    raise InvalidQueryError('Could not parse date: ' + val)


def _CalculatePastDate(days_ago, now=None):
  # type: (int, int) -> int
  """Calculates the timestamp N days ago from now."""
  if now is None:
    now = int(time.time())
  ts = now - days_ago * 24 * 60 * 60
  return ts


def QueryToSubqueries(query):
  # type (str) -> Sequence[str]
  """Splits a query into smaller queries based on Monorail's search syntax.

  This function handles parsing parentheses and OR statements in Monorail's
  search syntax. By doing this parsing for OR statements and parentheses up
  front in FrontendSearchPipeline, we are able to convert complex queries
  with lots of ORs into smaller, more easily cacheable query terms.

  These outputted subqueries should collectively return the same query results
  as the initial input query without containing any ORs or parentheses,
  allowing later search layers to parse queries without worrying about ORs
  or parentheses.

  Some examples of possible queries and their expected output:

  - '(A OR B) (C OR D) OR (E OR F)' -> ['A C', 'A D', 'B C', 'B D', 'E', 'F']
  - '(A) OR (B)' -> ['A', 'B']
  - '(A ((C) OR (D OR (E OR F))))' -> ['A C', 'A D', 'A E', 'A F']

  Where A, B, C, D, etc could be any list of conjunctions. ie: "owner:me",
  "Pri=1", "hello world Hotlist=test", "label!=a11y", etc

  Note: Monorail implicitly ANDs any query terms separated by a space. For
  the most part, AND functionality is handled at a later layer in search
  processing. However, this case becomes important here when considering the
  fact that a prentheses group can either be ANDed or ORed with terms that
  surround it.

  The _MultiplySubqueries helper is used to AND the results of different
  groups together whereas concatenating lists is used to OR subqueries
  together.

  Args:
    query: The initial query that was sent to the search.

  Returns:
    List of query fragments to be independently processed as search terms.

  Raises:
    InvalidQueryError if parentheses are unmatched.
  """
  tokens = _ValidateAndTokenizeQuery(query)

  # Using an iterator allows us to keep our current loop position across
  # helpers. This makes recursion a lot easier.
  token_iterator = PeekIterator(tokens)

  subqueries = _ParseQuery(token_iterator)

  if not len(subqueries):
    # Several cases, such as an empty query or a query with only parentheses
    # will result in an empty set of subqueries. In these cases, we still want
    # to give the search pipeline a single empty query to process.
    return ['']

  return subqueries


def _ParseQuery(token_iterator):
  # type (Sequence[proto.ast_pb2.QueryToken]) -> Sequence[str]
  """Recursive helper to convert query tokens into a list of subqueries.

  Parses a Query based on the following grammar (EBNF):

    Query             := OrGroup { [OrOperator] OrGroup }
    OrGroup           := AndGroup { AndGroup }
    AndGroup          := Subquery | ParenthesesGroup
    ParenthesesGroup  := "(" Query ")"
    Subquery          := /.+/
    OrOperator        := " OR "

  An important nuance is that two groups can be next to each other, separated
  only by a word boundary (ie: space or parentheses). In this case, they are
  implicitly ANDed. In practice, because unparenthesized fragments ANDed by
  spaces are stored as single tokens, we only need to handle the AND case when
  a parentheses group is implicitly ANDed with an adjacent group.

  Order of precedence is implemented by recursing through OR groups before
  recursing through AND groups.

  Args:
    token_iterator: Iterator over a list of query tokens.

  Returns:
    List of query fragments to be processed as search terms.

  Raises:
    InvalidQueryError if tokens were inputted in a format that does not follow
    our search grammar.
  """
  subqueries = []
  try:
    if token_iterator.peek().token_type == OR:
      # Edge case: Ignore empty OR groups at the starte of a ParenthesesGroup.
      # ie: "(OR A)" will be processed as "A"
      next(token_iterator)

    subqueries = _ParseOrGroup(token_iterator)

    while token_iterator.peek().token_type == OR:
      # Consume the OR tokens without doing anything with it.
      next(token_iterator)

      next_token = token_iterator.peek()
      if next_token.token_type == RIGHT_PAREN:
        # Edge case: Ignore empty OR groups at the end of a ParenthesesGroup.
        # ie: "(A OR)" will be processed as "A"
        return subqueries

      next_subqueries = _ParseOrGroup(token_iterator)

      # Concatenate results of OR groups together.
      subqueries = subqueries + next_subqueries

  except StopIteration:
    pass
  # Return when we've reached the end of the string.
  return subqueries


def _ParseOrGroup(token_iterator):
  # type (Sequence[proto.ast_pb2.QueryToken]) -> Sequence[str]
  """Recursive helper to convert a single "OrGroup" into subqueries.

  An OrGroup here is based on the following grammar:

    Query             := OrGroup { [OrOperator] OrGroup }
    OrGroup           := AndGroup { AndGroup }
    AndGroup          := Subquery | ParenthesesGroup
    ParenthesesGroup  := "(" Query ")"
    Subquery          := /.+/
    OrOperator        := " OR "

  Args:
    token_iterator: Iterator over a list of query tokens.

  Returns:
    List of query fragments to be processed as search terms.

  Raises:
    InvalidQueryError if tokens were inputted in a format that does not follow
    our search grammar.
  """
  subqueries = _ParseAndGroup(token_iterator)

  try:
    # Iterate until there are no more AND groups left to see.
    # Subquery or left parentheses are the possible starts of an AndGroup.
    while (token_iterator.peek().token_type == SUBQUERY or
           token_iterator.peek().token_type == LEFT_PAREN):

      # Find subqueries from the next AND group.
      next_subqueries = _ParseAndGroup(token_iterator)

      # Multiply all results across AND groups together.
      subqueries = _MultiplySubqueries(subqueries, next_subqueries)
  except StopIteration:
    pass

  return subqueries


def _ParseAndGroup(token_iterator):
  # type (Sequence[proto.ast_pb2.QueryToken]) -> Sequence[str]
  """Recursive helper to convert a single "AndGroup" into subqueries.

  An OrGroup here is based on the following grammar:

    Query             := OrGroup { [OrOperator] OrGroup }
    OrGroup           := AndGroup { AndGroup }
    AndGroup          := Subquery | ParenthesesGroup
    ParenthesesGroup  := "(" Query ")"
    Subquery          := /.+/
    OrOperator        := " OR "

  Args:
    token_iterator: Iterator over a list of query tokens.

  Returns:
    List of query fragments to be processed as search terms.

  Raises:
    InvalidQueryError if tokens were inputted in a format that does not follow
    our search grammar.
  """
  try:
    token = next(token_iterator)
    if token.token_type == LEFT_PAREN:
      if token_iterator.peek().token_type == RIGHT_PAREN:
        # Don't recurse into the ParenthesesGroup if there's nothing inside.
        next(token_iterator)
        return []

      # Recurse into the ParenthesesGroup.
      subqueries = _ParseQuery(token_iterator)

      # Next token should be a right parenthesis.
      next(token_iterator)

      return subqueries
    elif token.token_type == SUBQUERY:
      return [token.value]
    else:
      # This should not happen if other QueryToSubqueries helpers are working
      # properly.
      raise InvalidQueryError('Inputted tokens do not follow grammar.')
  except StopIteration:
    pass
  return []


def _ValidateAndTokenizeQuery(query):
  # type: (str) -> Sequence[proto.ast_pb2.QueryToken]
  """Converts the input query into a set of tokens for easier parsing.

  Tokenizing the query string before parsing allows us to not have to as many
  string manipulations while parsing, which simplifies our later code.

  Args:
    query: Query to tokenize.

  Returns:
    List of Token objects for use in query processing.

  Raises:
    InvalidQueryError if parentheses are unmatched.
  """
  tokens = []  # Function result
  count = 0  # Used for checking if parentheses are balanced
  s = ''  # Records current string fragment. Cleared when a token is added.

  for ch in query:
    if ch == '(':
      count += 1

      # Add subquery from before we hit this parenthesis.
      tokens.extend(_TokenizeSubqueryOnOr(s))
      s = ''

      tokens.append(ast_pb2.QueryToken(token_type=LEFT_PAREN))
    elif ch == ')':
      count -= 1

      if count < 0:
        # More closing parentheses then open parentheses.
        raise InvalidQueryError('Search query has unbalanced parentheses.')

      # Add subquery from before we hit this parenthesis.
      tokens.extend(_TokenizeSubqueryOnOr(s))
      s = ''

      tokens.append(ast_pb2.QueryToken(token_type=RIGHT_PAREN))
    else:
      s += ch

  if count != 0:
    raise InvalidQueryError('Search query has unbalanced parentheses.')

  # Add any trailing tokens.
  tokens.extend(_TokenizeSubqueryOnOr(s))

  return tokens


def _TokenizeSubqueryOnOr(subquery):
  # type: (str) -> Sequence[proto.ast_pb2.QueryToken]
  """Helper to split a subquery by OR and convert the result into tokens.

  Args:
    subquery: A string without parentheses to tokenize.

  Returns:
    Tokens for the subquery with OR tokens separating query strings if
    applicable.
  """
  if len(subquery) == 0:
    return []

  result = []
  fragments = subquery.split(OR_SYMBOL)
  for f in fragments:
    # Interleave the string fragments with OR tokens.
    result.append(ast_pb2.QueryToken(token_type=SUBQUERY, value=f.strip()))
    result.append(ast_pb2.QueryToken(token_type=OR))

  # Remove trailing OR.
  result.pop()

  # Trim empty strings at the beginning or end. ie: if subquery is ' OR ',
  # we want the list to be ['OR'], not ['', 'OR', ''].
  if len(result) > 1 and result[0].value == '':
    result.pop(0)
  if len(result) > 1 and result[-1].value == '':
    result.pop()
  return result


def _MultiplySubqueries(a, b):
  # type: (Sequence[str], Sequence[str]) -> Sequence[str]
  """Helper to AND subqueries from two separate lists.

  Args:
    a: First list of subqueries.
    b: Second list of subqueries.

  Returns:
    List with n x m subqueries.
  """
  if not len(a):
    return b
  if not len(b):
    return a
  res = []
  for q1 in a:
    for q2 in b:
      # AND two subqueries together by concatenating them.
      query = (q1.strip() + ' ' + q2.strip()).strip()
      res.append(query)
  return res


class PeekIterator:
  """Simple iterator with peek() functionality.

  Used by QueryToSubqueries to maintain state easily across recursive calls.
  """

  def __init__(self, source):
    # type: (Sequence[Any])
    self.__source = source
    self.__i = 0

  def peek(self):
    # type: () -> Any
    """Gets the next value in the iterator without side effects.

    Returns:
      Next value in iterator.

    Raises:
      StopIteration if you're at the end of the iterator.
    """
    if self.__i >= len(self.__source):
      raise StopIteration
    return self.__source[self.__i]

  def __iter__(self):
    # type: () -> Sequence[Any]
    """Return self to make iterator iterable."""
    return self

  def __repr__(self):
    # type: () -> str
    """Allow logging current iterator value for debugging."""
    try:
      return str(self.peek())
    except StopIteration:
      pass
    return 'End of PeekIterator'

  def next(self):
    # type: () -> Any
    """Gets the next value in the iterator and increments pointer.

    Returns:
      Next value in iterator.

    Raises:
      StopIteration if you're at the end of the iterator.
    """
    if self.__i >= len(self.__source):
      raise StopIteration
    value = self.__source[self.__i]
    self.__i += 1
    return value


class Error(Exception):
  """Base exception class for this package."""
  pass


class InvalidQueryError(Error):
  """Error raised when an invalid query is requested."""
  pass
# Copyright 2016 The Chromium Authors. All rights reserved. # Use of this source code is governed by a BSD-style # license that can be found in the LICENSE file or at # https://developers.google.com/open-source/licenses/bsd A set of functions that integrate the GAE search index with Monorail. # TODO(jrobbins): Consider re-implementing this whole file by using a # BNF syntax specification and a parser generator or library. # encodings # Operator used for OR statements. # Token types used for parentheses parsing. # Field types and operators # Mapping from user query comparison operators to our internal representation. # When the query has a leading minus, switch the operator for its opposite. # IS_DEFINED is handled separately. # This is a partial regular expression that matches all of our comparison # operators, such as =, 1=, >, and <.  Longer ones listed first so that the # shorter ones don't cause premature matches. # This RE extracts search terms from a subquery string. # E.g., ["division by zero"] # E.g., [stars>10] # E.g., [summary:"memory leak"] # E.g., [-workaround] # This RE is used to further decompose a comparison term into prefix, op, and # value.  E.g., [stars>10] or [is:open] or [summary:"memory leak"].  The prefix # can include a leading "-" to negate the comparison. # Predefined issue fields passed to the query parser. # attachment file names # number of attachment files # Add all _DATE_FIELDS to _ISSUE_FIELDS_LIST. # Do not treat strings that start with the below as key:value search terms. # See bugs.chromium.org/p/monorail/issues/detail?id=419 for more detail. # type: (str, str, Mapping[str, proto.tracker_pb2.FieldDef], #   proto.tracker_pb2.ProjectIssueConfig, Sequence[str], int) -> #     proto.ast_pb2.QueryAST Parse a user query and return a set of structure terms.    Args:     query: string with user's query.  E.g., 'Priority=High'.     scope: string search terms that define the scope in which the         query should be executed.  They are expressed in the same         user query language.  E.g., adding the canned query.     builtin_fields: dict {field_name: FieldDef(field_name, type)}         mapping field names to FieldDef objects for built-in fields.     harmonized_config: config for all the projects being searched.         @@@ custom field name is not unique in cross project search.          - custom_fields = {field_name: [fd, ...]}          - query build needs to OR each possible interpretation          - could be label in one project and field in another project.         @@@ what about searching across all projects?     warnings: optional list to accumulate warning messages.     now: optional timestamp for tests, otherwise time.time() is used.    Returns:     A QueryAST with conjunctions (usually just one), where each has a list of     Condition PBs with op, fields, str_values and int_values.  E.g., the query     [priority=high leak OR stars>100] over open issues would return     QueryAST(       Conjunction(Condition(EQ, [open_fd], [], [1]),                   Condition(EQ, [label_fd], ['priority-high'], []),                   Condition(TEXT_HAS, any_field_fd, ['leak'], [])),       Conjunction(Condition(EQ, [open_fd], [], [1]),                   Condition(GT, [stars_fd], [], [100])))    Raises:     InvalidQueryError: If a problem was detected in the user's query. # Convert the overall query into one or more OR'd subqueries. # Make a dictionary of all fields: built-in + custom in each project. # Only do non-enum fields because enums are stored as labels # type: (str, str, Mapping[str, proto.tracker_pb2.FieldDef], Sequence[str], #     int) -> proto.ast_pb2.Condition Parse part of a user query into a Conjunction PB. # type: (str, Mapping[str, proto.tracker_pb2.FieldDef], Sequence[str], #     int) -> proto.ast_pb2.Condition Parse one user query condition string into a Condition PB. # Do not treat as key:value search terms if any of the special prefixes match. # Special case handling to continue to support old date query terms from # code.google.com. See monorail:151 for more details. # Treat the cond as a full-text search term, which might be negated. # Construct a full-text Query object as a dry-run to validate that # the syntax is acceptable. # Flag a potential user misunderstanding. # type: (str, str, str, Mapping[str, proto.tracker_pb2.FieldDef]) -> #     proto.ast_pb2.Condition Parse one user structured query term into an internal representation.    Args:     prefix: The query operator, usually a field name.  E.g., summary. It can       also be special operators like "is" to test boolean fields.     op_str: the comparison operator.  Usually ":" or "=", but can be any OPS.     value: the value to compare against, e.g., term to find in that field.     fields: dict {name_lower: [FieldDef, ...]} for built-in and custom fields.     now: optional timestamp for tests, otherwise time.time() is used.    Returns:     A Condition PB. # Quick-OR is a convenient way to write one condition that matches any one of # multiple values, like set membership.  E.g., [Priority=High,Critical]. # Ignore empty values caused by duplicated or trailing commas. E.g., # [Priority=High,,Critical,] is equivalent to [Priority=High,Critical]. # Search entries with or without any value in the specified field. # Possible search for phase field with any value. # Look for that field with any value. # Look for any label with that prefix. # Search entries with certain gates. # Determine hotlist query type. # If prefix is not 'hotlist', quick_or_vals is empty, or qov # does not contain ':', is_fields will remain True # search built-in and custom fields. E.g., summary. # Note: if first matching field is date-type, we assume they all are. # TODO(jrobbins): better handling for rare case where multiple projects # define the same custom field name, and one is a date and another is not. # Since it is not a field, treat it as labels, E.g., Priority. # Convert substring match to key-value match if user typed 'foo:bar'. # type: (str, Sequence[str]) -> Sequence[str] Parse a query string into a list of individual condition strings.    Args:     query: UTF-8 encoded search query string.     warnings: list to accumulate warning messages.    Returns:     A list of query condition strings. # Convert to unicode then search for distinct terms. # Case 1: Quoted phrases, e.g., ["hot dog"]. # Case 2: Comparisons # It looked like a key:value cond, but not exactly, so treat it # as fulltext search.  It is probably a tiny bit of source code. # Case 3: Simple words. # pragma: no coverage # type: (str, int) -> int Convert the user-entered date into timestamp. # Support timestamp value such as opened>1437671476 # TODO(jrobbins): future: take timezones into account. # TODO(jrobbins): for now, explain to users that "today" is # actually now: the current time, not 12:01am in their timezone. # In fact, it is not very useful because everything in the system # happened before the current time. # type: (int, int) -> int Calculates the timestamp N days ago from now. # type (str) -> Sequence[str] Splits a query into smaller queries based on Monorail's search syntax.    This function handles parsing parentheses and OR statements in Monorail's   search syntax. By doing this parsing for OR statements and parentheses up   front in FrontendSearchPipeline, we are able to convert complex queries   with lots of ORs into smaller, more easily cacheable query terms.    These outputted subqueries should collectively return the same query results   as the initial input query without containing any ORs or parentheses,   allowing later search layers to parse queries without worrying about ORs   or parentheses.    Some examples of possible queries and their expected output:    - '(A OR B) (C OR D) OR (E OR F)' -> ['A C', 'A D', 'B C', 'B D', 'E', 'F']   - '(A) OR (B)' -> ['A', 'B']   - '(A ((C) OR (D OR (E OR F))))' -> ['A C', 'A D', 'A E', 'A F']    Where A, B, C, D, etc could be any list of conjunctions. ie: "owner:me",   "Pri=1", "hello world Hotlist=test", "label!=a11y", etc    Note: Monorail implicitly ANDs any query terms separated by a space. For   the most part, AND functionality is handled at a later layer in search   processing. However, this case becomes important here when considering the   fact that a prentheses group can either be ANDed or ORed with terms that   surround it.    The _MultiplySubqueries helper is used to AND the results of different   groups together whereas concatenating lists is used to OR subqueries   together.    Args:     query: The initial query that was sent to the search.    Returns:     List of query fragments to be independently processed as search terms.    Raises:     InvalidQueryError if parentheses are unmatched. # Using an iterator allows us to keep our current loop position across # helpers. This makes recursion a lot easier. # Several cases, such as an empty query or a query with only parentheses # will result in an empty set of subqueries. In these cases, we still want # to give the search pipeline a single empty query to process. # type (Sequence[proto.ast_pb2.QueryToken]) -> Sequence[str] Recursive helper to convert query tokens into a list of subqueries.    Parses a Query based on the following grammar (EBNF):      Query             := OrGroup { [OrOperator] OrGroup }     OrGroup           := AndGroup { AndGroup }     AndGroup          := Subquery | ParenthesesGroup     ParenthesesGroup  := "(" Query ")"     Subquery          := /.+/     OrOperator        := " OR "    An important nuance is that two groups can be next to each other, separated   only by a word boundary (ie: space or parentheses). In this case, they are   implicitly ANDed. In practice, because unparenthesized fragments ANDed by   spaces are stored as single tokens, we only need to handle the AND case when   a parentheses group is implicitly ANDed with an adjacent group.    Order of precedence is implemented by recursing through OR groups before   recursing through AND groups.    Args:     token_iterator: Iterator over a list of query tokens.    Returns:     List of query fragments to be processed as search terms.    Raises:     InvalidQueryError if tokens were inputted in a format that does not follow     our search grammar. # Edge case: Ignore empty OR groups at the starte of a ParenthesesGroup. # ie: "(OR A)" will be processed as "A" # Consume the OR tokens without doing anything with it. # Edge case: Ignore empty OR groups at the end of a ParenthesesGroup. # ie: "(A OR)" will be processed as "A" # Concatenate results of OR groups together. # Return when we've reached the end of the string. # type (Sequence[proto.ast_pb2.QueryToken]) -> Sequence[str] Recursive helper to convert a single "OrGroup" into subqueries.    An OrGroup here is based on the following grammar:      Query             := OrGroup { [OrOperator] OrGroup }     OrGroup           := AndGroup { AndGroup }     AndGroup          := Subquery | ParenthesesGroup     ParenthesesGroup  := "(" Query ")"     Subquery          := /.+/     OrOperator        := " OR "    Args:     token_iterator: Iterator over a list of query tokens.    Returns:     List of query fragments to be processed as search terms.    Raises:     InvalidQueryError if tokens were inputted in a format that does not follow     our search grammar. # Iterate until there are no more AND groups left to see. # Subquery or left parentheses are the possible starts of an AndGroup. # Find subqueries from the next AND group. # Multiply all results across AND groups together. # type (Sequence[proto.ast_pb2.QueryToken]) -> Sequence[str] Recursive helper to convert a single "AndGroup" into subqueries.    An OrGroup here is based on the following grammar:      Query             := OrGroup { [OrOperator] OrGroup }     OrGroup           := AndGroup { AndGroup }     AndGroup          := Subquery | ParenthesesGroup     ParenthesesGroup  := "(" Query ")"     Subquery          := /.+/     OrOperator        := " OR "    Args:     token_iterator: Iterator over a list of query tokens.    Returns:     List of query fragments to be processed as search terms.    Raises:     InvalidQueryError if tokens were inputted in a format that does not follow     our search grammar. # Don't recurse into the ParenthesesGroup if there's nothing inside. # Recurse into the ParenthesesGroup. # Next token should be a right parenthesis. # This should not happen if other QueryToSubqueries helpers are working # properly. # type: (str) -> Sequence[proto.ast_pb2.QueryToken] Converts the input query into a set of tokens for easier parsing.    Tokenizing the query string before parsing allows us to not have to as many   string manipulations while parsing, which simplifies our later code.    Args:     query: Query to tokenize.    Returns:     List of Token objects for use in query processing.    Raises:     InvalidQueryError if parentheses are unmatched. # Function result # Used for checking if parentheses are balanced # Records current string fragment. Cleared when a token is added. # Add subquery from before we hit this parenthesis. # More closing parentheses then open parentheses. # Add subquery from before we hit this parenthesis. # Add any trailing tokens. # type: (str) -> Sequence[proto.ast_pb2.QueryToken] Helper to split a subquery by OR and convert the result into tokens.    Args:     subquery: A string without parentheses to tokenize.    Returns:     Tokens for the subquery with OR tokens separating query strings if     applicable. # Interleave the string fragments with OR tokens. # Remove trailing OR. # Trim empty strings at the beginning or end. ie: if subquery is ' OR ', # we want the list to be ['OR'], not ['', 'OR', '']. # type: (Sequence[str], Sequence[str]) -> Sequence[str] Helper to AND subqueries from two separate lists.    Args:     a: First list of subqueries.     b: Second list of subqueries.    Returns:     List with n x m subqueries. # AND two subqueries together by concatenating them. Simple iterator with peek() functionality.    Used by QueryToSubqueries to maintain state easily across recursive calls. # type: (Sequence[Any]) # type: () -> Any Gets the next value in the iterator without side effects.      Returns:       Next value in iterator.      Raises:       StopIteration if you're at the end of the iterator. # type: () -> Sequence[Any] Return self to make iterator iterable. # type: () -> str Allow logging current iterator value for debugging. # type: () -> Any Gets the next value in the iterator and increments pointer.      Returns:       Next value in iterator.      Raises:       StopIteration if you're at the end of the iterator. Base exception class for this package. Error raised when an invalid query is requested.