Skip to content

Commit 29c9a92

Browse files
authored
Merge pull request #29 from cloudblue/feature/LITE-12104_nested-filters
LITE-12104 Operator "tuple" for filtration by nested fields added
2 parents ffea7ce + 99b53c8 commit 29c9a92

File tree

8 files changed

+187
-15
lines changed

8 files changed

+187
-15
lines changed

dj_rql/_dataclasses.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,18 +21,22 @@ def __init__(self, queryset, select_data, filter_tree, filter_node=None, filter_
2121

2222

2323
class FilterArgs:
24-
def __init__(self, filter_name, operator, str_value, list_operator=None, **kwargs):
24+
def __init__(self, filter_name, operator, str_value, list_operator=None,
25+
namespace=None, **kwargs):
2526
"""
2627
:param str filter_name: Full filter name (f.e. ns1.ns2.filter1)
2728
:param str operator: RQL operator (f.e. eq, like, etc.)
2829
:param str str_value: Raw value from RQL query
2930
:param str or None list_operator: This is filled only if operation is done within IN or OUT
31+
:param list or None namespace: List of namespaces
3032
:param dict kwargs: Other auxiliary data (f.e. to ease custom filtering)
3133
"""
32-
self.filter_name = filter_name
34+
self.filter_basename = filter_name
35+
self.filter_name = '.'.join((namespace or []) + [filter_name])
3336
self.operator = operator
3437
self.str_value = str_value
3538
self.list_operator = list_operator
39+
self.namespace = namespace
3640

3741
self.filter_lookup = kwargs.get('filter_lookup')
3842
self.django_lookup = kwargs.get('django_lookup')

dj_rql/filter_cls.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,12 @@ def build_q_for_filter(self, data):
242242
"""
243243
filter_name, operator, str_value = data.filter_name, data.operator, data.str_value
244244
list_operator = data.list_operator
245+
filter_basename, namespace = data.filter_basename, data.namespace
246+
247+
if namespace and filter_basename == RQL_SEARCH_PARAM:
248+
raise RQLFilterLookupError(details={
249+
'error': f'Filter "{filter_basename}" can be applied only on top level.',
250+
})
245251

246252
if filter_name == RQL_SEARCH_PARAM:
247253
return self._build_q_for_search(operator, str_value)

dj_rql/grammar.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
1717
term: expr_term
1818
| logical
19+
| tuple
1920
2021
expr_term: comp
2122
| listing
@@ -59,9 +60,10 @@
5960
| _L_BRACE sign_prop (_COMMA sign_prop)* _R_BRACE
6061
6162
val: prop
63+
| tuple
6264
| QUOTED_VAL
6365
| UNQUOTED_VAL
64-
66+
6567
prop: comp_term
6668
| logical_term
6769
| list_term
@@ -70,6 +72,8 @@
7072
| select_term
7173
| PROP
7274
75+
tuple: _TUPLE _L_BRACE (comp|searching) (_COMMA (comp|searching))* _R_BRACE
76+
7377
!sign_prop: ["+"|"-"] prop
7478
7579
!comp_term: "eq" | "ne" | "gt" | "ge" | "lt" | "le"
@@ -93,6 +97,7 @@
9397
_AND: "and"
9498
_OR: "or"
9599
_NOT: "not"
100+
_TUPLE: "t"
96101
97102
_COMMA: ","
98103
_L_BRACE: "("

dj_rql/transformer.py

Lines changed: 64 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ def _extract_comparison(cls, args):
4242
def _get_value(obj):
4343
while isinstance(obj, Tree):
4444
obj = obj.children[0]
45+
46+
if isinstance(obj, Q):
47+
return obj
48+
4549
return obj.value
4650

4751
def sign_prop(self, args):
@@ -72,13 +76,47 @@ class RQLToDjangoORMTransformer(BaseRQLTransformer):
7276
They are applied later in FilterCls. This is done on purpose, because transformer knows
7377
nothing about the mappings between filter names and orm fields.
7478
"""
79+
NAMESPACE_PROVIDERS = ('comp', 'listing')
80+
NAMESPACE_FILLERS = ('prop',)
81+
NAMESPACE_ACTIVATORS = ('tuple',)
82+
7583
def __init__(self, filter_cls_instance):
7684
self._filter_cls_instance = filter_cls_instance
7785

7886
self._ordering = []
7987
self._select = []
8088
self._filtered_props = set()
8189

90+
self._namespace = []
91+
self._active_namespace = 0
92+
93+
self.__visit_tokens__ = False
94+
95+
def _push_namespace(self, tree):
96+
if tree.data in self.NAMESPACE_PROVIDERS:
97+
self._namespace.append(None)
98+
elif tree.data in self.NAMESPACE_ACTIVATORS:
99+
self._active_namespace = len(self._namespace)
100+
elif (tree.data in self.NAMESPACE_FILLERS
101+
and self._namespace
102+
and self._namespace[-1] is None):
103+
self._namespace[-1] = self._get_value(tree)
104+
105+
def _pop_namespace(self, tree):
106+
if tree.data in self.NAMESPACE_PROVIDERS:
107+
self._namespace.pop()
108+
elif tree.data in self.NAMESPACE_ACTIVATORS:
109+
self._active_namespace -= 1
110+
111+
def _get_current_namespace(self):
112+
return self._namespace[:self._active_namespace]
113+
114+
def _transform_tree(self, tree):
115+
self._push_namespace(tree)
116+
ret_value = super()._transform_tree(tree)
117+
self._pop_namespace(tree)
118+
return ret_value
119+
82120
@property
83121
def ordering_filters(self):
84122
return self._ordering
@@ -94,9 +132,19 @@ def start(self, args):
94132

95133
def comp(self, args):
96134
prop, operation, value = self._extract_comparison(args)
97-
self._filtered_props.add(prop)
98135

99-
return self._filter_cls_instance.build_q_for_filter(FilterArgs(prop, operation, value))
136+
if isinstance(value, Q):
137+
if operation == ComparisonOperators.EQ:
138+
return value
139+
else:
140+
return ~value
141+
142+
filter_args = FilterArgs(prop, operation, value, namespace=self._get_current_namespace())
143+
self._filtered_props.add(filter_args.filter_name)
144+
return self._filter_cls_instance.build_q_for_filter(filter_args)
145+
146+
def tuple(self, args):
147+
return Q(*args)
100148

101149
def logical(self, args):
102150
operation = args[0].data
@@ -119,10 +167,17 @@ def listing(self, args):
119167

120168
q = Q()
121169
for value_tree in args[2:]:
122-
field_q = self._filter_cls_instance.build_q_for_filter(FilterArgs(
123-
prop, f_op, self._get_value(value_tree),
124-
list_operator=operation,
125-
))
170+
value = self._get_value(value_tree)
171+
if isinstance(value, Q):
172+
if f_op == ComparisonOperators.EQ:
173+
field_q = value
174+
else:
175+
field_q = ~value
176+
else:
177+
field_q = self._filter_cls_instance.build_q_for_filter(FilterArgs(
178+
prop, f_op, value,
179+
list_operator=operation,
180+
))
126181
if operation == ListOperators.IN:
127182
q |= field_q
128183
else:
@@ -135,9 +190,9 @@ def listing(self, args):
135190
def searching(self, args):
136191
# like, ilike
137192
operation, prop, val = tuple(self._get_value(args[index]) for index in range(3))
138-
self._filtered_props.add(prop)
139-
140-
return self._filter_cls_instance.build_q_for_filter(FilterArgs(prop, operation, val))
193+
filter_args = FilterArgs(prop, operation, val, namespace=self._get_current_namespace())
194+
self._filtered_props.add(filter_args.filter_name)
195+
return self._filter_cls_instance.build_q_for_filter(filter_args)
141196

142197
def ordering(self, args):
143198
props = args[1:]

setup.cfg

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ test = pytest
55
exclude = .idea,.git,venv*/,.eggs/,*.egg-info,_generated_filters*.py
66
max-line-length = 100
77
show-source = True
8-
ignore = W605
8+
ignore = W503,W605
99

1010
[tool:pytest]
1111
addopts = --show-capture=no --create-db --nomigrations --junitxml=tests/reports/out.xml --cov=dj_rql --cov-report xml:tests/reports/coverage.xml

tests/test_drf/test_django_filters_backend.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,9 @@ def test_compatibility_modify_initial_query(backend):
6464
('limit=10;k__in=2', True),
6565
('(k=v;k=z)', False),
6666
('limit=10;k__in=2;k=y)', True),
67+
('t(email=1)', False),
68+
('author=t(email=email)', False),
69+
('k__in=v&t(auhtor=1)', False),
6770
))
6871
def test_old_syntax(mocker, query, expected):
6972
request = mocker.MagicMock(query_params=QueryDict(query))

tests/test_filter_cls/test_apply_filters.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,93 @@ def test_out():
158158
assert apply_out_listing_filters('23') == books
159159

160160

161+
@pytest.mark.django_db
162+
@pytest.mark.parametrize('filter_string', (
163+
't(author.email={email},title=null())',
164+
't(search={email})',
165+
't(ge(published.at,{published_at}))',
166+
't(author.publisher.id={publisher_id})',
167+
'title=null()&t(author.email={email})',
168+
't(author=t(email={email}))',
169+
'author=t(email={email},is_male=true)',
170+
'author=t(publisher=t(id={publisher_id}))',
171+
'author=t(email={email},ne(is_male,false))',
172+
'ne(author,t(email={second_book_email},is_male=true))',
173+
'and(author=t(email={email}),author=t(is_male=true))',
174+
'and(title=null(),author=t(is_male=true,publisher=t(id={publisher_id})))',
175+
'in(author.email,({email}))',
176+
'in(author,(t(publisher.id=null()),t(email={email})))',
177+
'out(author,(t(email={second_book_email})))',
178+
))
179+
def test_tuple(filter_string):
180+
books = create_books()
181+
comp_filter = filter_string.format(
182+
email=books[0].author.email,
183+
published_at=books[0].published_at.date(),
184+
publisher_id=books[0].author.publisher.id,
185+
second_book_email=books[1].author.email,
186+
)
187+
assert apply_filters(comp_filter) == [books[0]]
188+
189+
190+
@pytest.mark.django_db
191+
@pytest.mark.parametrize('filter_string', (
192+
'author=t(like=1)',
193+
'author=t(ilike=1)',
194+
'author=t(in=1)',
195+
'author=t(out=1)',
196+
'author=t(eq=1)',
197+
'author=t(ne=1)',
198+
'author=t(and=1)',
199+
'author=t(or=1)',
200+
'author=t(limit=1)',
201+
'author=t(offset=1)',
202+
))
203+
def test_tuple_syntax_terms_not_fail(filter_string):
204+
books = create_books()
205+
assert apply_filters(filter_string) == books
206+
207+
208+
@pytest.mark.parametrize('filter_string', (
209+
't()',
210+
't(1=1)',
211+
'author=t(t(t(name=1))'
212+
'author=t(male)',
213+
'author=t(test=in(male,(true,false)))',
214+
'in(t(is_male=true),(author))',
215+
'select(t(author.publisher))',
216+
'author=t(limit(email))',
217+
'author=t(offset(email))',
218+
'author=t(select(email))',
219+
'author=t(ordering(email))',
220+
'author=t(and(a=1,b=2))',
221+
'author=t(or(a=1,b=2))',
222+
'author=t(not(a=1))',
223+
'author=t(search(x,term))',
224+
'auhtor=t(select(+test))',
225+
))
226+
def test_tuple_parse_error(filter_string):
227+
with pytest.raises(RQLFilterParsingError) as e:
228+
apply_filters(filter_string)
229+
230+
expected = 'Bad filter query.'
231+
assert e.value.details['error'] == expected
232+
233+
234+
def test_tuple_search_inside_namespace():
235+
with pytest.raises(RQLFilterLookupError) as e:
236+
apply_filters('author=t(search=term)')
237+
238+
expected = 'Filter "search" can be applied only on top level.'
239+
assert e.value.details['error'] == expected
240+
241+
242+
def test_tuple_lookup_error():
243+
with pytest.raises(RQLFilterLookupError) as e:
244+
apply_filters('author=t(ge(email,1))')
245+
assert e.value.details == {'filter': 'author.email', 'lookup': 'ge', 'value': '1'}
246+
247+
161248
@pytest.mark.django_db
162249
def test_null():
163250
books = create_books()

tests/test_filter_cls/utils.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,25 @@
22
# Copyright © 2020 Ingram Micro Inc. All rights reserved.
33
#
44

5-
from tests.dj_rf.models import Book
5+
from datetime import timedelta
6+
7+
from django.utils import timezone
8+
9+
from tests.dj_rf.models import Author, Book, Publisher
610
from tests.dj_rf.view import apply_annotations
711

12+
813
book_qs = apply_annotations(Book.objects.order_by('id'))
914

1015

1116
def create_books(count=2):
12-
Book.objects.bulk_create([Book() for _ in range(count)])
17+
for i in range(count):
18+
author = Author.objects.create(
19+
name=f'author{i}',
20+
email=f'author{i}@example.com',
21+
is_male=True,
22+
publisher=Publisher.objects.create(),
23+
)
24+
Book.objects.create(author=author, published_at=timezone.now() - timedelta(days=i))
1325
books = list(book_qs)
1426
return books

0 commit comments

Comments
 (0)