"""Logic for Vector order search.""" import json import uuid from typing import Any from flask import g from opensearchpy import serializer from opensearchpy.helpers import query, search from opensearchpy.helpers.query import Query from opensearchpy.helpers.response import Response from werkzeug.exceptions import BadRequest, NotFound from vectororder import config from vectororder.connectors import dynamodb, elasticsearch from vectororder.constants import ( error as error_const, search as search_const, ) def search_vector_order_details( *, offset: int | None = None, limit: int | None = None, order_by: list[str] | None = None, multiple: dict[Any, Any] | None = None, single: dict[Any, Any] | None = None, date_range: dict[Any, Any] | None = None, fulltext: dict[Any, Any] | None = None, inner_disjunction: dict[Any, Any] | None = None, ) -> tuple[list[dict[Any, Any]], int]: """Search Vector orders in Elasticsearch. Arguments format examples: multiple: {'user_id': [1, 2], 'order_id': [3, 4]} fulltext: {'error_log': 'Something suspicious is happening here!'} Args: offset (int): Pagination offset - the number of items to skip. limit (int): Pagination limit - the maximum number of items to return. order_by (list): Field names to order search results by. multiple (dict): Fields, which contain multiple values, translated into 'terms' ES query. single (dict): Fields, which contain a single value. translated into 'term' ES query. date_range (dict): Fields that hold date ranges, translated into 'range' ES query. fulltext (dict): Fields that should use a fulltext search, translated into 'match' ES query. inner_disjunction (dict): Groups of fields iterables that are logically OR'ed and translated into 'should' ES query. Returns: response.Response: Response with a list of hits as dictionaries at the 'items' key. """ g.ows.log.debug( "Got Vector order search fields: " "term=%s, terms=%s, date_range=%s, match=%s, inner_disjunction=%s", single, multiple, date_range, fulltext, inner_disjunction, ) query_expr = build_query_expression( multiple=multiple, single=single, date_range=date_range, fulltext=fulltext, inner_disjunction=inner_disjunction, ) if query_expr is None: g.ows.log.info("Got empty Vector order search request.") raise BadRequest(error_const.ERROR_MESSAGE_SEARCH_REQUEST_EMPTY) es_result = _execute_search_query( query_expr=query_expr, index=elasticsearch.get_index_name(), offset=offset, limit=limit, order_by=order_by, ) total_hits = es_result.hits.total if not isinstance(total_hits, int) and hasattr(total_hits, "value"): total_hits = total_hits.value return [hit.to_dict() for hit in es_result.hits], total_hits def build_query_expression( multiple: dict[Any, Any] | None = None, single: dict[Any, Any] | None = None, date_range: dict[Any, Any] | None = None, fulltext: dict[Any, Any] | None = None, inner_disjunction: dict[Any, Any] | None = None, ) -> Query | None: """Build Elasticsearch query expression. The process is based on matching fields from different categories to differrent Elasticsearch query types: term, terms, match, range, should. Args: multiple (dict): Fields, which contain multiple values, translated into 'terms' ES query. single (dict): Fields, which contain a single value. translated into 'term' ES query. date_range (dict): Fields that hold date ranges, translated into 'range' ES query. fulltext (dict): Fields that should use a fulltext search, translated into 'match' ES query. inner_disjunction (dict): Groups of fields iterables that are logically OR'ed and translated into 'should' ES query. Returns: elasticsearch_dsl.query.Q: Elasticsearch query expression or None if all arguments were empty. """ if not any((multiple, single, date_range, fulltext, inner_disjunction)): return None if single is None: single = {} if multiple is None: multiple = {} if fulltext is None: fulltext = {} if date_range is None: date_range = {} if inner_disjunction is None: inner_disjunction = {} query_expr = query.Q() for k, v in single.items(): query_expr = query_expr & query.Q("term", **{k: v}) for k, v in multiple.items(): query_expr = query_expr & query.Q("terms", **{k: v}) for k, v in fulltext.items(): query_type, value = _get_match_type_with_value(v) query_expr = query_expr & query.Q(query_type, **{k: value}) for k, v in date_range.items(): date_range_expr = _build_range_expression(field_name=k, date_range=v) if date_range_expr: query_expr = query_expr & query.Q("range", **date_range_expr) for queries in inner_disjunction.values(): inner_query = query.Q() for single_query in queries: # Recursively build inner 'OR' query expression. expr = build_query_expression(**single_query) # elasticsearch_dsl can not 'OR' with an empty query. if inner_query != query.MatchAll(): inner_query = inner_query | expr else: inner_query = expr # All top-level expressions must be satisfied. query_expr = query_expr & inner_query return query_expr def get_ordering_fields(order_by: list[str] | None) -> list[str]: """Get a list of fields to order results by. This takes the input iterable, appends constants.search.DOCUMENT_SORT_FIELD and returns the result as a new list. The input is not modified. Args: order_by (iterable): An iterable of fields to order by. Returns: list: A new list of fields to order results by. """ if order_by is None: order_by = [] elif isinstance(order_by, str): # Forgive an attempt to pass a plain string as a sort field. order_by = [order_by] else: order_by = list(order_by) order_by.append(search_const.DOCUMENT_SORT_FIELD) return order_by def _get_match_type_with_value(value: str) -> tuple[str, str]: """Get Elasticsearch fulltext match type and value. Values enclosed in quotation marks are searched with 'match_phrase' queries, others - with 'match'. Args: value (str): A value to search for. Returns: tuple: The match type string and the value. """ match_type = "match" if value and (value[0] == value[-1] == search_const.ES_PHRASE_BOUNDARY): match_type = "match_phrase" value = value[1:-1] # Remove quotation marks from the value. return match_type, value def _build_range_expression( *, field_name: str, date_range: tuple[Any, Any] ) -> dict[str, Any] | None: """Build Elasticsearch date range expression. Args: field_name (str): An Elasticsearch field name. date_range (iterable): A two-elements iterable with indexed access. Returns: dict: A dictionary representing range search expression or None if both date values are missing. Example: {'field_name': {'gte': '2018-01-11', 'lte': '2018-01-20'}} """ from_date_expr = {"gte": date_range[0]} if date_range[0] else None to_date_expr = {"lte": date_range[1]} if date_range[1] else None if not any((from_date_expr, to_date_expr)): return None range_expr: dict[str, Any] = {field_name: {}} if from_date_expr: range_expr[field_name].update(from_date_expr) if to_date_expr: range_expr[field_name].update(to_date_expr) return range_expr def _execute_search_query( *, offset: int | None = None, limit: int | None = None, order_by: list[str] | None = None, query_expr: Query, index: str, ) -> Response: """Execute Elasticsearch query. Args: offset (int): Pagination offset - the number of items to skip. limit (int): Pagination limit - the maximum number of items to return. order_by (list): Field names to order search results by. query_expr (elasticsearch_dsl.Q): A query expression. index (str): Elasticsearch index name to use. Returns: elasticsearch_dsl.response.Response: A response iterable. """ search_request = search.Search(using=elasticsearch.get_client(), index=index) order_by = get_ordering_fields(order_by) # Apply sort options here. The sort() call supports '-' prefixes to # indicate a descending order. search_request = search_request.sort(*order_by) if offset is None: offset = 0 options = {"from": offset} if limit: options.update({"size": limit}) search_request = search_request.update_from_dict(options) search_query = search_request.filter(query_expr) g.ows.log.debug( "Raw Vector order ES search query: %s %s cache", serializer.JSONSerializer().dumps(search_query.to_dict()), "without" if config.OPENSEARCH_IGNORE_CACHE else "with", ) return search_query.execute(ignore_cache=config.OPENSEARCH_IGNORE_CACHE) def save_search_request(request_data: dict[Any, Any]) -> str: """Save search request to DynamoDB. Args: request_data (dict): Search request data. Returns: str: Search request ID. """ dynamodb_resource = dynamodb.get_dynamodb_resource() table = dynamodb_resource.Table(config.DDB_SEARCH_REQUESTS_TABLE) request_id = uuid.uuid4().hex table.put_item( Item={ "id": request_id, "payload_json": json.dumps(request_data), } ) return request_id def get_search_request(request_id: str) -> dict[Any, Any]: """Get search request from DynamoDB. Args: request_id (str): Search request ID. Returns: str: Search request data dictionary """ dynamodb_resource = dynamodb.get_dynamodb_resource() table = dynamodb_resource.Table(config.DDB_SEARCH_REQUESTS_TABLE) result = table.get_item(Key={"id": request_id}) if "Item" not in result: raise NotFound(error_const.ERROR_MESSAGE_SEARCH_REQUEST_NOT_FOUND) request_json = result["Item"]["payload_json"] return json.loads(request_json) # type: ignore[arg-type]