| # 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 helpers functions for fulltext search.""" |
| |
| from __future__ import division |
| from __future__ import print_function |
| from __future__ import absolute_import |
| |
| import logging |
| |
| from google.appengine.api import search |
| |
| import settings |
| from proto import ast_pb2 |
| from proto import tracker_pb2 |
| from search import query2ast |
| |
| # GAE search API can only respond with 500 results per call. |
| _SEARCH_RESULT_CHUNK_SIZE = 500 |
| |
| |
| def BuildFTSQuery(query_ast_conj, fulltext_fields): |
| """Convert a Monorail query AST into a GAE search query string. |
| |
| Args: |
| query_ast_conj: a Conjunction PB with a list of Comparison PBs that each |
| have operator, field definitions, string values, and int values. |
| All Conditions should be AND'd together. |
| fulltext_fields: a list of string names of fields that may exist in the |
| fulltext documents. E.g., issue fulltext documents have a "summary" |
| field. |
| |
| Returns: |
| A string that can be passed to AppEngine's search API. Or, None if there |
| were no fulltext conditions, so no fulltext search should be done. |
| """ |
| fulltext_parts = [ |
| _BuildFTSCondition(cond, fulltext_fields) |
| for cond in query_ast_conj.conds] |
| if any(fulltext_parts): |
| return ' '.join(fulltext_parts) |
| else: |
| return None |
| |
| |
| def _BuildFTSCondition(cond, fulltext_fields): |
| """Convert one query AST condition into a GAE search query string.""" |
| if cond.op == ast_pb2.QueryOp.NOT_TEXT_HAS: |
| neg = 'NOT ' |
| elif cond.op == ast_pb2.QueryOp.TEXT_HAS: |
| neg = '' |
| else: |
| return '' # FTS only looks at TEXT_HAS and NOT_TEXT_HAS |
| |
| parts = [] |
| |
| for fd in cond.field_defs: |
| if fd.field_name in fulltext_fields: |
| pattern = fd.field_name + ':"%s"' |
| elif fd.field_name == ast_pb2.ANY_FIELD: |
| pattern = '"%s"' |
| elif fd.field_id and fd.field_type == tracker_pb2.FieldTypes.STR_TYPE: |
| pattern = 'custom_' + str(fd.field_id) + ':"%s"' |
| else: |
| pattern = 'pylint does not handle else-continue' |
| continue # This issue field is searched via SQL. |
| |
| for value in cond.str_values: |
| # Strip out quotes around the value. |
| value = value.strip('"') |
| special_prefixes_match = any( |
| value.startswith(p) for p in query2ast.NON_OP_PREFIXES) |
| if not special_prefixes_match: |
| value = value.replace(':', ' ') |
| assert ('"' not in value), 'Value %r has a quote in it' % value |
| parts.append(pattern % value) |
| |
| if parts: |
| return neg + '(%s)' % ' OR '.join(parts) |
| else: |
| return '' # None of the fields were fulltext fields. |
| |
| |
| def ComprehensiveSearch(fulltext_query, index_name): |
| """Call the GAE search API, and keep calling it to get all results. |
| |
| Args: |
| fulltext_query: string in the GAE search API query language. |
| index_name: string name of the GAE fulltext index to hit. |
| |
| Returns: |
| A list of integer issue IIDs or project IDs. |
| """ |
| search_index = search.Index(name=index_name) |
| |
| try: |
| response = search_index.search(search.Query( |
| fulltext_query, |
| options=search.QueryOptions( |
| limit=_SEARCH_RESULT_CHUNK_SIZE, returned_fields=[], ids_only=True, |
| cursor=search.Cursor()))) |
| except ValueError as e: |
| raise query2ast.InvalidQueryError(e.message) |
| |
| logging.info('got %d initial results', len(response.results)) |
| ids = [int(result.doc_id) for result in response] |
| |
| remaining_iterations = int( |
| (settings.fulltext_limit_per_shard - 1) // _SEARCH_RESULT_CHUNK_SIZE) |
| for _ in range(remaining_iterations): |
| if not response.cursor: |
| break |
| response = search_index.search(search.Query( |
| fulltext_query, |
| options=search.QueryOptions( |
| limit=_SEARCH_RESULT_CHUNK_SIZE, returned_fields=[], ids_only=True, |
| cursor=response.cursor))) |
| logging.info( |
| 'got %d more results: %r', len(response.results), response.results) |
| ids.extend(int(result.doc_id) for result in response) |
| |
| logging.info('FTS result ids %d', len(ids)) |
| return ids |