<reponame>jamesmistry/weaveq<gh_stars>0
# -*- coding: utf-8 -*-

"""!
@package weaveq.parser Classes for parsing and compiling textual queries to WeaveQ objects.
"""

# Copyright 2017 <NAME>.
# 
# Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), 
# to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, 
# and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
# 
# The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 
# DEALINGS IN THE SOFTWARE.

from __future__ import print_function, absolute_import
import pyparsing
import abc
import copy

import weaveq.query
import weaveq.relations
import weaveq.wqexception

class DataSourceBuilder(object):
    """!
    @brief Abstract data source builder functor.

    Defines an interface for client callbacks to be requested to build weaveq.query.DataSource objects as a result of query parsing. Note that builders don't have to inherit from this class, but must at least expose the interface it defines.
    """

    __metaclass__ = abc.ABCMeta

    @abc.abstractmethod
    def __call__(self, source_uri, filter_string):
        """!
        Called when a weaveq.query.DataSource object needs to be built in order to run a WeaveQ query.

        @param source_uri: the URI describing the source of the data (specified by the string literal preceding the @c #as statement in the WeaveQ query)
        @param filter_string: the filter specified to be applied to the data at source (specified by the text within pipe characters in the @c #filter clause), or @c None if no filter was specified

        @return An object either of the type weaveq.query.DataSource or exposing a compatible interface (e.g. an Elasticsearch DSL query), or @c None if building failed

        @see weaveq.query.DataSource
        """
        pass

class TextQuery(object):
    """!
    @brief Textual query parser and compiler.

    Compiles a textual query into a WeaveQ object. Data source URIs are specified in the query text, so the client of this class must supply a DataSourceBuilder object for the conversion of these URIs to weaveq.query.DataSource objects.
    """

    STEP_TYPE_SEED = 0
    STEP_TYPE_PIVOT = 1
    STEP_TYPE_JOIN = 2

    NODE_TYPE_ATOM = 0
    NODE_TYPE_COMPOUND = 1
    NODE_TYPE_OPERATOR = 2

    def __init__(self, data_source_builder):
        """!
        Constructor.

        @param data_source_builder: converts data source URIs to weaveq.query.DataSource objects

        @see weaveq.query.DataSource
        """
        self._identifier = pyparsing.Word(pyparsing.alphanums + "_" + "." + "@" + "$" + "?")
        self._string_literal = pyparsing.QuotedString('"', escChar='\\')
        self._field_equality_op = pyparsing.oneOf("= !=")
        self._field_relationship = pyparsing.Group(self._identifier - self._field_equality_op - self._identifier)
        self._field_expr = pyparsing.infixNotation(self._field_relationship, [(pyparsing.Keyword("and"), 2, pyparsing.opAssoc.LEFT), (pyparsing.Keyword("or"), 2, pyparsing.opAssoc.LEFT),]).setResultsName("field_relations")
        self._where_clause = pyparsing.Keyword("#where") - self._field_expr
        self._filter_expr = pyparsing.QuotedString(quoteChar="|", escChar="\\")
        self._source_spec = self._string_literal.setResultsName("source_uri") - pyparsing.Keyword("#as") - self._identifier.setResultsName("source_alias") - pyparsing.Optional(pyparsing.Keyword("#filter") - self._filter_expr.setResultsName("source_filter_string"))
        self._pivot_clause = pyparsing.Keyword("#pivot-to").setResultsName("step_action") - self._source_spec - self._where_clause
        self._join_options = (pyparsing.Keyword("#field-name") - self._identifier.setResultsName("field_name")) | pyparsing.Keyword("#exclude-empty").setResultsName("exclude_empty") | pyparsing.Keyword("#array").setResultsName("array")
        self._join_clause = pyparsing.Keyword("#join-to").setResultsName("step_action") - self._source_spec - self._where_clause - pyparsing.ZeroOrMore(self._join_options)
        self._process_clause = self._pivot_clause | self._join_clause
        self._seed_clause = pyparsing.Keyword("#from").setResultsName("step_action") - self._source_spec
        
        self._seed_clause.setParseAction(self._create_step)
        self._pivot_clause.setParseAction(self._create_step)
        self._join_clause.setParseAction(self._create_step)

        self._query = self._seed_clause - pyparsing.OneOrMore(self._process_clause)

        self.data_source_builder = data_source_builder

        self._parsed_query = []
        self._source_by_alias = {}

    def _order_operands(self, operands):
        """!
        Checks the order of field expression operands and re-orders them, if necessary.

        @param operands: a list containing the two comparison operands to order

        @return a list of two operands in the correct order
        """
        name_l = operands[0]
        name_r = operands[1]

        left_indx = -1
        right_indx = -1

        try:
            left_indx = self._source_by_alias[name_l[0:name_l.index(".")]]
        except KeyError:
            raise weaveq.wqexception.TextQueryCompileError("The alias {0} is not assigned to a source".format(name_l))

        try:
            right_indx = self._source_by_alias[name_r[0:name_r.index(".")]]
        except KeyError:
            raise weaveq.wqexception.TextQueryCompileError("The alias {0} is not assigned to a source".format(name_r))

        if (left_indx == len(self._parsed_query) - 1):
            if (right_indx != len(self._parsed_query) - 2):
                raise weaveq.wqexception.TextQueryCompileError("The alias {0} is out of scope".format(name_r))

            operands[0] = name_r[name_r.index(".")+1:]
            operands[1] = name_l[name_l.index(".")+1:]
        elif (left_indx != len(self._parsed_query) - 2):
            raise weaveq.wqexception.TextQueryCompileError("The alias {0} is out of scope".format(name_l))
        elif (right_indx != len(self._parsed_query) - 1):
            raise weaveq.wqexception.TextQueryCompileError("The alias {0} is out of scope".format(name_r))
        else:
            operands[0] = name_l[name_l.index(".")+1:]
            operands[1] = name_r[name_r.index(".")+1:]

        return operands

    def _compile_sub_expr(self, lhs, op, rhs):
        """!
        Converts a pair of operands and comparison operator into a weaveq.relations.F object

        @param lhs: left-hand side operand as a fully-qualified alias and field name
        @param op: comparison operator as the string representation of the operator
        @param rhs: right-hand side operand as a fully-qualified alias and field name

        @return the weaveq.relations.F result
        """
        sub_expr = None
        operands = self._order_operands([lhs, rhs])
        if (op == '='):
            sub_expr = (weaveq.relations.F(operands[0]) == weaveq.relations.F(operands[1]))
        elif (op == '!='):
            sub_expr = (weaveq.relations.F(operands[0]) != weaveq.relations.F(operands[1]))

        return sub_expr

    def _node_type(self, node):
        """!
        Determines the type of a node from PyParsing's infix notation type parsed out of the where clause

        @param node: the node whose type will be determined

        @return an integer denoting the type of the node

        @see TextQuery.NODE_TYPE_ATOM
        @see TextQuery.NODE_TYPE_COMPOUND
        @see TextQuery.NODE_TYPE_OPERATOR
        """
        if ((len(node) == 3) and (isinstance(node, pyparsing.ParseResults))):
            if (isinstance(node[0], str) and isinstance(node[1], str) and isinstance(node[2], str)):
                return TextQuery.NODE_TYPE_ATOM
            else:
                return TextQuery.NODE_TYPE_COMPOUND
        elif (isinstance(node, str)):
            return TextQuery.NODE_TYPE_OPERATOR
        else:
            return TextQuery.NODE_TYPE_COMPOUND

    def _reduce_stack(self, expr_stack):
        """!
        Performs the operation described by an expression stack of the form [operand] [logical-operator] [operand] and stores the result as the single element in a new expression stack

        @return the expression stack after reduction
        """
        if (len(expr_stack) == 3):
            if (expr_stack[-2] == "and"):
                expr_stack = [(expr_stack[-3] & expr_stack[-1])]
            elif (expr_stack[-2] == "or"):
                expr_stack = [(expr_stack[-3] | expr_stack[-1])]

        return expr_stack

    def _transform_field_relations(self, rel, expr_stack = None):
        """!
        Converts a nested list representation of a syntax tree, as produced by PyParsing, into the corresponding weaveq.relations.F representation.

        @param rel: infix notation output from PyParsing
        @param expr_stack: current expression stack

        @return 1-element expression stack containing the results of the given expression
        """
        if (expr_stack is None):
            expr_stack = []

        node_type = self._node_type(rel)

        if (node_type == TextQuery.NODE_TYPE_ATOM):
            expr_stack.append(self._compile_sub_expr(rel[0], rel[1], rel[2]))
            expr_stack = self._reduce_stack(expr_stack)
        elif (node_type == TextQuery.NODE_TYPE_OPERATOR):
            expr_stack.append(rel)
        elif (node_type == TextQuery.NODE_TYPE_COMPOUND):
            for expr in rel:
                expr_stack.append(self._transform_field_relations(expr)[-1])
                expr_stack = self._reduce_stack(expr_stack)

        return expr_stack

    def _create_step(self, tokens):
        """!
        Callback during parsing to produce an intermediate representation of a weaveq.query.WeaveQ

        @param tokens: tokens from PyParsing
        """
        field_relations = []
        if (tokens.step_action == "#from"):
            self._parsed_query.append({"type" : TextQuery.STEP_TYPE_SEED})
        elif (tokens.step_action == "#pivot-to"):
            field_relations = tokens.field_relations
            self._parsed_query.append({"type" : TextQuery.STEP_TYPE_PIVOT})
        elif (tokens.step_action == "#join-to"):
            field_relations = tokens.field_relations
            self._parsed_query.append({"type" : TextQuery.STEP_TYPE_JOIN})
            if (len(tokens.field_name) > 0):
                self._parsed_query[-1]["field_name"] = tokens.field_name
            else:
                self._parsed_query[-1]["field_name"] = None
            if (len(tokens.exclude_empty) > 0):
                self._parsed_query[-1]["exclude_empty"] = True
            else:
                self._parsed_query[-1]["exclude_empty"] = False
            if (len(tokens.array) > 0):
                self._parsed_query[-1]["array"] = True
            else:
                self._parsed_query[-1]["array"] = False
        else:
            raise weaveq.wqexception.TextQueryCompileError("Unexpected query step action: {0}".format(tokens.step_action))

        filter_string = tokens.source_filter_string if len(tokens.source_filter_string) > 0 else None
        data_source = self.data_source_builder(tokens.source_uri, filter_string)
        if (data_source is None):
            raise weaveq.wqexception.TextQueryCompileError("Failed to build data source for {0}: builder failed".format(tokens.source_uri))

        self._parsed_query[-1]["source_uri"] = tokens.source_uri
        self._parsed_query[-1]["source_filter_string"] = filter_string
        self._parsed_query[-1]["source_alias"] = tokens.source_alias
        self._source_by_alias[tokens.source_alias] = len(self._parsed_query) - 1
        self._parsed_query[-1]["data_source"] = data_source

        if (len(field_relations) == 0):
            self._parsed_query[-1]["relations"] = None
            self._parsed_query[-1]["field_expression"] = None
        else:
            self._parsed_query[-1]["relations"] = field_relations

            try:
                self._parsed_query[-1]["field_expression"] = self._transform_field_relations(self._parsed_query[-1]["relations"])[-1]
            except Exception as e:
                raise weaveq.wqexception.TextQueryCompileError("Error transforming field relations: {0}".format(str(e)))

    def compile_query(self, query_string):
        """!
        Compiles a textual query into a weaveq.query.WeaveQ object.

        @param query_string: string containing textual query

        @return weaveq.query.WeaveQ result
        """

        # Parse the text query

        self._parsed_query = []
        self._source_by_alias = {}

        try:
            self._parse(query_string)
        except pyparsing.ParseBaseException as e:
            raise weaveq.wqexception.TextQueryCompileError("Parse error at col {0}: {1}".format(e.column, e.msg))

        # Create a query object from the parse results
        result = None
        for parse_result in self._parsed_query:
            if (parse_result["type"] == TextQuery.STEP_TYPE_SEED):
                if (result is not None):
                    raise weaveq.wqexception.TextQueryCompileError("Unexpected seed query out of sequence")

                result = weaveq.query.WeaveQ(parse_result["data_source"])
            else:
                if (result is None):
                    raise weaveq.wqexception.TextQueryCompileError("Unexpected non-seed query out of sequence")

                if (parse_result["type"] == TextQuery.STEP_TYPE_PIVOT):
                    result = result.pivot_to(parse_result["data_source"], parse_result["field_expression"])
                elif (parse_result["type"] == TextQuery.STEP_TYPE_JOIN):
                    result = result.join_to(parse_result["data_source"], parse_result["field_expression"], field=parse_result["field_name"], array=parse_result["array"], exclude_empty_joins=parse_result["exclude_empty"])

        return result

    def _parse(self, query_string):
        (self._query + pyparsing.stringEnd).parseString(query_string)

