diff --git a/pyproject.toml b/pyproject.toml index 2f89db2..202dbbd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "pysaql" -version = "0.6.0" +version = "0.7.0" description = "Python SAQL query builder" authors = ["Jonathan Drake "] license = "BSD-3-Clause" diff --git a/pysaql/__init__.py b/pysaql/__init__.py index 502b8c3..eca4176 100644 --- a/pysaql/__init__.py +++ b/pysaql/__init__.py @@ -1,3 +1,3 @@ """Python SAQL query builder""" -__version__ = "0.6.0" +__version__ = "0.7.0" diff --git a/pysaql/scalar.py b/pysaql/scalar.py index c569589..a6e0486 100644 --- a/pysaql/scalar.py +++ b/pysaql/scalar.py @@ -57,7 +57,7 @@ def __or__(self, obj: Any) -> BinaryOperation: binary operation """ - return BinaryOperation(operator.or_, self, obj, wrap=True) + return BinaryOperation(operator.or_, self, obj) def __invert__(self) -> UnaryOperation: """Creates a unary operation using the `inv` operator @@ -69,58 +69,6 @@ def __invert__(self) -> UnaryOperation: return UnaryOperation(operator.inv, self) -class BinaryOperation(BooleanOperation): - """Represents a binary operation""" - - def __init__(self, op: Callable, left: Any, right: Any, wrap: bool = False) -> None: - """Initializer - - Args: - op: Operator function that accepts two operands - left: Left operand - right: Right operand - wrap: Flag that indicates whether the stringified operation should be - wrapped in parentheses to denote precedence. Defaults to False. - - """ - super().__init__() - if op not in OPERATOR_STRINGS: - operators = ", ".join(f"operator.{fn.__name__}" for fn in OPERATOR_STRINGS) - raise ValueError(f"Operator must be one of: {operators}. Provided: {op}") - self.op = op - self.left = left - self.right = right - self.wrap = wrap - - def to_string(self) -> str: - """Cast the binary operation to a string""" - s = f"{stringify(self.left)} {OPERATOR_STRINGS[self.op]} {stringify(self.right)}" - if self.wrap: - s = f"({s})" - - return s - - -class UnaryOperation(BooleanOperation): - """Represents a unary operation""" - - def __init__(self, op: Callable, value: Any) -> None: - """Initializer - - Args: - op: Operator function that accepts one argument - value: Value to pass to the operator - - """ - super().__init__() - self.op = op - self.value = value - - def to_string(self) -> str: - """Cast the unary operation to a string""" - return f"{OPERATOR_STRINGS[self.op]} {stringify(self.value)}" - - class Scalar(BooleanOperation, ABC): """Represents a scalar expression""" @@ -268,13 +216,66 @@ def in_(self, iterable: Union[Sequence, Expression]) -> BinaryOperation: return BinaryOperation(operator.contains, self, iterable) +class BinaryOperation(Scalar): + """Represents a binary operation""" + + def __init__(self, op: Callable, left: Any, right: Any, wrap: bool = False) -> None: + """Initializer + + Args: + op: Operator function that accepts two operands + left: Left operand + right: Right operand + wrap: Flag that indicates whether the stringified operation should be + wrapped in parentheses to denote precedence. Defaults to False. + + """ + super().__init__() + if op not in OPERATOR_STRINGS: + operators = ", ".join(f"operator.{fn.__name__}" for fn in OPERATOR_STRINGS) + raise ValueError(f"Operator must be one of: {operators}. Provided: {op}") + self.op = op + self.left = left + self.right = right + self.wrap = wrap + for operand in (self.left, self.right): + if isinstance(operand, BinaryOperation): + operand.wrap = True + + def to_string(self) -> str: + """Cast the binary operation to a string""" + s = f"{stringify(self.left)} {OPERATOR_STRINGS[self.op]} {stringify(self.right)}" + if self.wrap: + s = f"({s})" + + return s + + +class UnaryOperation(Scalar): + """Represents a unary operation""" + + def __init__(self, op: Callable, value: Any) -> None: + """Initializer + + Args: + op: Operator function that accepts one argument + value: Value to pass to the operator + + """ + super().__init__() + self.op = op + self.value = value + + def to_string(self) -> str: + """Cast the unary operation to a string""" + return f"{OPERATOR_STRINGS[self.op]} {stringify(self.value)}" + + class field(Scalar): """Represents a field (column) in the data stream""" - name: str - def __init__(self, name: str) -> None: - """Initializer + """Represents a field (column) in the data stream Args: name: Name of the field @@ -286,3 +287,21 @@ def __init__(self, name: str) -> None: def to_string(self) -> str: """Cast the field to a string""" return escape_identifier(self.name) + + +class literal(Scalar): + """Represents a literal value""" + + def __init__(self, value: Any) -> None: + """Represents a literal value + + Args: + value: Literal value + + """ + super().__init__() + self.value = value + + def to_string(self) -> str: + """Cast the literal to a string""" + return stringify(self.value) diff --git a/tests/unit/test_query.py b/tests/unit/test_query.py index 4d40565..84f7bb9 100644 --- a/tests/unit/test_query.py +++ b/tests/unit/test_query.py @@ -84,7 +84,7 @@ def test_complex(): """q1 = load "opportunities";""", """q1 = foreach q1 generate 'name', coalesce('number', 'other number', 0);""", """q1 = fill q1 by (dateCols=('Year', 'Month', "Y-M"), partition='Type');""", - """q1 = filter q1 by 'name' == "abc" && ! 'flag' && ('number' > 0 || 'number' < 0) && 'empty' is null && 'list' in ["ny", "ma"] && 'closed_date' in [date(2022, 1).."2 months ago"];""", + """q1 = filter q1 by ((((('name' == "abc") && ! 'flag') && (('number' > 0) || ('number' < 0))) && ('empty' is null)) && ('list' in ["ny", "ma"])) && ('closed_date' in [date(2022, 1).."2 months ago"]);""", """q1 = foreach q1 generate sum('amount') over ([..2] partition by ('region', 'state') order by sum('amount') desc) as 'total amount', dense_rank() over ([..] partition by 'county' order by 'region' asc) as 'total amount';""", """q2 = cogroup q0 by 'Day in Week' full, q1 by 'Day in Week';""", ] diff --git a/tests/unit/test_scalar.py b/tests/unit/test_scalar.py index 5f8e2c1..9544eef 100644 --- a/tests/unit/test_scalar.py +++ b/tests/unit/test_scalar.py @@ -1,7 +1,7 @@ """Contains unit tests for the scalar module""" -from pysaql.scalar import field +from pysaql.scalar import field, literal def test_alias(): @@ -59,6 +59,16 @@ def test_truediv(): assert str(field("foo") / 10) == """'foo' / 10""" +def test_truediv__literal_right(): + """Should allow a literal value as the right operand when the left operand is a binary operation""" + assert str((field("foo") / 10) * 100) == """('foo' / 10) * 100""" + + +def test_truediv__literal_left(): + """Should require a literal value as the left operand when the right operand is a binary operation""" + assert str(literal(100) * (field("foo") / 10)) == """100 * ('foo' / 10)""" + + def test_neg(): """Should return string for neg operation""" assert str(-field("foo")) == """- 'foo'""" @@ -72,14 +82,16 @@ def test_in(): def test_and(): """Should return string for and operation""" assert ( - str((field("foo") > 0) & (field("foo") < 10)) == """'foo' > 0 && 'foo' < 10""" + str((field("foo") > 0) & (field("foo") < 10)) + == """('foo' > 0) && ('foo' < 10)""" ) def test_or(): """Should return string for or operation""" assert ( - str((field("foo") > 0) | (field("foo") < 10)) == """('foo' > 0 || 'foo' < 10)""" + str((field("foo") > 0) | (field("foo") < 10)) + == """('foo' > 0) || ('foo' < 10)""" ) diff --git a/tests/unit/test_stream.py b/tests/unit/test_stream.py index 2105f16..4766fbd 100644 --- a/tests/unit/test_stream.py +++ b/tests/unit/test_stream.py @@ -92,7 +92,7 @@ def test_filter__multiple(): """Should filter by a multiple conditions""" stream = Stream() stream.filter(field("name") == "foo", field("bar") == "baz") - assert str(stream) == """q0 = filter q0 by 'name' == "foo" && 'bar' == "baz";""" + assert str(stream) == """q0 = filter q0 by ('name' == "foo") && ('bar' == "baz");""" def test_limit__invalid():