diff --git a/py_rql/constants.py b/py_rql/constants.py index 7d3a5bb..6e1239b 100644 --- a/py_rql/constants.py +++ b/py_rql/constants.py @@ -61,6 +61,9 @@ class FilterLookups: I_LIKE = 'ilike' """`Case-insensitive like` operator""" + RANGE = 'range' + """`range` operator""" + @classmethod def numeric(cls, with_null: bool = True) -> set: """ @@ -73,7 +76,7 @@ def numeric(cls, with_null: bool = True) -> set: (set): a set with the default lookups. """ return cls._add_null( - {cls.EQ, cls.NE, cls.GE, cls.GT, cls.LT, cls.LE, cls.IN, cls.OUT}, with_null, + {cls.EQ, cls.NE, cls.GE, cls.GT, cls.LT, cls.LE, cls.IN, cls.OUT, cls.RANGE}, with_null, ) @classmethod @@ -122,6 +125,7 @@ class ComparisonOperators: class ListOperators: IN = 'in' OUT = 'out' + RANGE = 'range' class LogicalOperators: diff --git a/py_rql/grammar.py b/py_rql/grammar.py index 57c790e..715b55d 100644 --- a/py_rql/grammar.py +++ b/py_rql/grammar.py @@ -26,6 +26,7 @@ | searching | ordering | select + | range | _L_BRACE expr_term _R_BRACE logical: and_op @@ -65,6 +66,8 @@ select: select_term _signed_props _signed_props: _L_BRACE _R_BRACE | _L_BRACE sign_prop (_COMMA sign_prop)* _R_BRACE + +range: range_term _L_BRACE prop _COMMA val _COMMA val _R_BRACE val: prop | tuple @@ -89,6 +92,7 @@ !search_term: "like" | "ilike" !ordering_term: "ordering" !select_term: "select" +!range_term: "range" PROP: /[a-zA-Z]/ /[\w\-\.]/* diff --git a/py_rql/operators.py b/py_rql/operators.py index f040a4f..9108c61 100644 --- a/py_rql/operators.py +++ b/py_rql/operators.py @@ -54,6 +54,10 @@ def ilike(a, b): return like(a.lower(), b.lower()) +def range_op(a, b): + return b[0] <= a <= b[1] + + def get_operator_func_by_operator(op): mapping = { ComparisonOperators.EQ: eq, @@ -64,6 +68,7 @@ def get_operator_func_by_operator(op): ComparisonOperators.LT: lt, ListOperators.IN: in_op, ListOperators.OUT: out_op, + ListOperators.RANGE: range_op, f'{LogicalOperators.AND}_op': and_op, f'{LogicalOperators.OR}_op': or_op, f'{LogicalOperators.NOT}_op': not_op, diff --git a/py_rql/transformer.py b/py_rql/transformer.py index 5e8007e..461bbfb 100644 --- a/py_rql/transformer.py +++ b/py_rql/transformer.py @@ -91,6 +91,10 @@ def searching(self, args): operation, prop, val = tuple(self._get_value(args[index]) for index in range(3)) return self._get_func_for_lookup(prop, operation, val) + def range(self, args): + operation, prop, *val = tuple(self._get_value(args[index]) for index in range(4)) + return self._get_func_for_lookup(prop, operation, val) + def _get_func_for_lookup(self, prop, operation, val): self.filter_cls.validate_lookup(prop, operation) diff --git a/tests/test_transformer/test_range.py b/tests/test_transformer/test_range.py new file mode 100644 index 0000000..0819df0 --- /dev/null +++ b/tests/test_transformer/test_range.py @@ -0,0 +1,80 @@ +# +# Copyright © 2023 Ingram Micro Inc. All rights reserved. +# +import pytest + +from py_rql.cast import get_default_cast_func_for_type +from py_rql.constants import FilterTypes, ListOperators +from py_rql.helpers import apply_operator +from py_rql.operators import get_operator_func_by_operator + + +@pytest.mark.parametrize( + 'value', + ( + ['10', '10.3'], + ['10', '-10.3'], + ), +) +@pytest.mark.parametrize('filter_type', (FilterTypes.DECIMAL, FilterTypes.FLOAT)) +@pytest.mark.parametrize('op', (ListOperators.RANGE,)) +def test_numeric(mocker, filter_factory, filter_type, op, value): + functools = mocker.patch('py_rql.transformer.functools') + flt = filter_factory([{'filter': 'prop', 'type': filter_type}]) + query = f'{op}(prop,{",".join(value)})' + flt.filter(query, []) + cast_func = get_default_cast_func_for_type(filter_type) + functools.partial.assert_called_once_with( + apply_operator, + 'prop', + get_operator_func_by_operator(op), + [cast_func(v) for v in value], + ) + + +@pytest.mark.parametrize('value', (['10', '-103'],)) +@pytest.mark.parametrize('op', (ListOperators.RANGE,)) +def test_numeric_int(mocker, filter_factory, op, value): + functools = mocker.patch('py_rql.transformer.functools') + flt = filter_factory([{'filter': 'prop', 'type': FilterTypes.INT}]) + query = f'{op}(prop,{",".join(value)})' + flt.filter(query, []) + cast_func = get_default_cast_func_for_type(FilterTypes.INT) + functools.partial.assert_called_once_with( + apply_operator, + 'prop', + get_operator_func_by_operator(op), + [cast_func(v) for v in value], + ) + + +@pytest.mark.parametrize('value', (['2020-01-01', '1932-03-31'],)) +@pytest.mark.parametrize('op', (ListOperators.RANGE,)) +def test_numeric_date(mocker, filter_factory, op, value): + functools = mocker.patch('py_rql.transformer.functools') + flt = filter_factory([{'filter': 'prop', 'type': FilterTypes.DATE}]) + query = f'{op}(prop,{",".join(value)})' + flt.filter(query, []) + cast_func = get_default_cast_func_for_type(FilterTypes.DATE) + functools.partial.assert_called_once_with( + apply_operator, + 'prop', + get_operator_func_by_operator(op), + [cast_func(v) for v in value], + ) + + +@pytest.mark.parametrize('value', (['2022-02-08T07:57:57+01:00', '2022-02-08T07:57:57'],)) +@pytest.mark.parametrize('op', (ListOperators.RANGE,)) +def test_numeric_datetime(mocker, filter_factory, op, value): + functools = mocker.patch('py_rql.transformer.functools') + flt = filter_factory([{'filter': 'prop', 'type': FilterTypes.DATETIME}]) + query = f'{op}(prop,{",".join(value)})' + flt.filter(query, []) + cast_func = get_default_cast_func_for_type(FilterTypes.DATETIME) + functools.partial.assert_called_once_with( + apply_operator, + 'prop', + get_operator_func_by_operator(op), + [cast_func(v) for v in value], + )