diff --git a/README.md b/README.md index e96167e..4077c60 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,8 @@ class ModelFilterClass(RQLFilterClass): DISTINCT - Boolean flag, that specifies if queryset must always be DISTINCT SELECT - Boolean flag, that specifies if Filter Class supports select operations and queryset optimizations OPENAPI_SPECIFICATION - Python class that renders OpenAPI specification + MAX_ORDERING_LENGTH_IN_QUERY - Integer max allowed number of provided ordering filters in query ordering expression + ALLOWED_ORDERING_PERMUTATIONS_IN_QUERY - Set of tuples of strings to specify a set of allowed ordering permutations Filters can be set in two ways: 1) string (default settings are calculated from ORM) diff --git a/dj_rql/filter_cls.py b/dj_rql/filter_cls.py index af99bec..76d984b 100644 --- a/dj_rql/filter_cls.py +++ b/dj_rql/filter_cls.py @@ -5,6 +5,7 @@ import re from collections import defaultdict from datetime import datetime +from itertools import chain from typing import Set from uuid import uuid4 @@ -58,6 +59,13 @@ class RQLFilterClass: """A list or tuple of filters definitions.""" EXTENDED_SEARCH_ORM_ROUTES = () + """List of additional Django ORM fields for search.""" + + MAX_ORDERING_LENGTH_IN_QUERY = 5 + """Max allowed number of provided ordering filters in query ordering expression.""" + + ALLOWED_ORDERING_PERMUTATIONS_IN_QUERY = None + """Property to specify a set of allowed ordering permutations (default `None`).""" DISTINCT = False """If True, a `SELECT DISTINCT` will always be executed (default `False`).""" @@ -107,6 +115,16 @@ def _validate_init(self): e = 'Extended search ORM routes must be iterable.' assert isinstance(self.EXTENDED_SEARCH_ORM_ROUTES, iterable_types), e + e = 'Max ordering length must be integer.' + assert isinstance(self.MAX_ORDERING_LENGTH_IN_QUERY, int), e + + perms = self.ALLOWED_ORDERING_PERMUTATIONS_IN_QUERY + if perms: + e = 'Allowed ordering permutations must be a set of tuples of string filter names.' + assert isinstance(perms, set), e + assert all(isinstance(t, tuple) for t in perms), e + assert all(isinstance(s, str) for s in chain.from_iterable(perms)), e + def _get_init_filters(self): return self.FILTERS @@ -120,8 +138,10 @@ def _default_init(self, filters): self.select_tree = {} self.default_exclusions = set() self.annotations = {} + self.allowed_ordering_permutations = None self._build_filters(filters) + self._validate_and_store_allowed_ordering_permutations() self._extend_annotations() def _init_from_class(self, instance): @@ -132,6 +152,7 @@ def _init_from_class(self, instance): 'select_tree', 'default_exclusions', 'annotations', + 'allowed_ordering_permutations', ) for attr in copied_attributes: setattr(self, attr, getattr(instance, attr)) @@ -542,12 +563,21 @@ def __apply_field_optimizations(self, qs, data, node): def _apply_ordering(self, qs, properties): if len(properties) == 0: return qs - elif len(properties) > 1: + + if len(properties) > 1: raise RQLFilterParsingError(details={ 'error': 'Bad ordering filter: query can contain only one ordering operation.', }) + if len(properties[0]) > self.MAX_ORDERING_LENGTH_IN_QUERY: + raise RQLFilterParsingError(details={ + 'error': 'Bad ordering filter: max allowed number is {n}.'.format( + n=self.MAX_ORDERING_LENGTH_IN_QUERY, + ), + }) + ordering_fields = [] + perm = [] for prop in properties[0]: filter_name, sign = self._get_filter_name_with_sign_for_ordering(prop) if filter_name not in self.ordering_filters: @@ -555,6 +585,7 @@ def _apply_ordering(self, qs, properties): 'error': 'Bad ordering filter: {0}.'.format(filter_name), }) + perm.append('{0}{1}'.format(sign, filter_name)) filters = self.filters[filter_name] if not isinstance(filters, list): filters = [filters] @@ -565,6 +596,12 @@ def _apply_ordering(self, qs, properties): ordering_name = self._get_filter_ordering_name(filter_item, filter_name) ordering_fields.append('{0}{1}'.format(sign, ordering_name)) + perms = self.allowed_ordering_permutations + if perms and tuple(perm) not in perms: + raise RQLFilterParsingError(details={ + 'error': 'Bad ordering filter: permutation not allowed.', + }) + return qs.order_by(*ordering_fields) @staticmethod @@ -766,6 +803,20 @@ def _extend_annotations(self): self.annotations.update(dict(extended_annotations)) + def _validate_and_store_allowed_ordering_permutations(self): + perms = self.ALLOWED_ORDERING_PERMUTATIONS_IN_QUERY + if perms: + for s in chain.from_iterable(perms): + filter_name = s[1:] if s and s[0] in ('+', '-') else s + + e = 'Wrong configuration of allowed ordering permutations: {n}.'.format(n=s) + assert filter_name in self.ordering_filters, e + + self.allowed_ordering_permutations = { + tuple(s[1:] if s[0] == '+' else s for s in t) + for t in perms + } + @classmethod def _is_field_supported(cls, field): return isinstance(field, SUPPORTED_FIELD_TYPES) diff --git a/tests/test_filter_cls/test_apply_filters.py b/tests/test_filter_cls/test_apply_filters.py index cc6b79a..f20a6f7 100644 --- a/tests/test_filter_cls/test_apply_filters.py +++ b/tests/test_filter_cls/test_apply_filters.py @@ -352,6 +352,31 @@ def test_bad_ordering_filter(): assert e.value.details['error'] == 'Bad ordering filter: id.' +def test_too_much_ordering_filters(): + with pytest.raises(RQLFilterParsingError) as e: + apply_filters('ordering(id,id,id,id,id,id)') + + assert e.value.details['error'] == 'Bad ordering filter: max allowed number is 5.' + + +@pytest.mark.django_db +@pytest.mark.parametrize('perms, q', ( + ({('author.email',)}, '-published.at'), + ({('-author.email',)}, 'author.email'), + ({('d_id', '-d_id')}, 'd_id,author.email'), + ({('d_id', 'author.email')}, 'd_id'), + ({('d_id', 'author.email'), ('author.email', '-published.at')}, 'author.email,published.at'), +)) +def test_bad_ordering_permutation(perms, q): + class CustomCls(BooksFilterClass): + ALLOWED_ORDERING_PERMUTATIONS_IN_QUERY = perms + + with pytest.raises(RQLFilterParsingError) as e: + list(CustomCls(book_qs).apply_filters(f'ordering({q})')) + + assert e.value.details['error'] == 'Bad ordering filter: permutation not allowed.' + + @pytest.mark.django_db def test_search(): title = 'book' @@ -402,6 +427,13 @@ def build_q_for_custom_filter(self, *args, **kwargs): @pytest.mark.django_db def test_custom_filter_ordering(generate_books): class CustomCls(BooksFilterClass): + ALLOWED_ORDERING_PERMUTATIONS_IN_QUERY = { + ('author.email', 'published.at'), + ('ordering_filter',), + ('-ordering_filter',), + ('-ordering_filter', 'author.email'), + } + def build_name_for_custom_ordering(self, filter_name): return 'id' @@ -412,6 +444,7 @@ def assert_ordering(self, filter_name, expected): CustomCls(book_qs).assert_ordering('ordering_filter', [books[0], books[1]]) CustomCls(book_qs).assert_ordering('-ordering_filter', [books[1], books[0]]) + CustomCls(book_qs).assert_ordering('-ordering_filter,author.email', [books[1], books[0]]) @pytest.mark.django_db diff --git a/tests/test_filter_cls/test_initialization.py b/tests/test_filter_cls/test_initialization.py index 62ae76c..3803b22 100644 --- a/tests/test_filter_cls/test_initialization.py +++ b/tests/test_filter_cls/test_initialization.py @@ -126,6 +126,53 @@ class Cls(BooksFilterClass): assert str(e.value) == 'Extended search ORM routes must be iterable.' +@pytest.mark.parametrize('v', ('5', None, 1.23, [])) +def test_wrong_ordering_length_setup(v): + class Cls(BooksFilterClass): + MAX_ORDERING_LENGTH_IN_QUERY = v + + with pytest.raises(AssertionError) as e: + Cls(empty_qs) + assert str(e.value) == 'Max ordering length must be integer.' + + +@pytest.mark.parametrize('v', ( + 5, + [['name']], + {'name'}, + {(5,)}, + {('x',), ('y', None)}, +)) +def test_wrong_ordering_permutations_setup(v): + class Cls(BooksFilterClass): + ALLOWED_ORDERING_PERMUTATIONS_IN_QUERY = v + + with pytest.raises(AssertionError) as e: + Cls(empty_qs) + + expected = 'Allowed ordering permutations must be a set of tuples of string filter names.' + assert str(e.value) == expected + + +@pytest.mark.parametrize('v, expected', ( + ({('x',)}, 'Wrong configuration of allowed ordering permutations: x.'), + ({('-d_id', '+')}, 'Wrong configuration of allowed ordering permutations: +.'), + ({('',)}, 'Wrong configuration of allowed ordering permutations: .'), + ( + {('author.email', '+published.at'), ('fsm', '-title')}, + 'Wrong configuration of allowed ordering permutations: -title.', + ), +)) +def test_wrong_ordering_permutations_filter_name_provided(v, expected): + class Cls(BooksFilterClass): + ALLOWED_ORDERING_PERMUTATIONS_IN_QUERY = v + + with pytest.raises(AssertionError) as e: + Cls(empty_qs) + + assert str(e.value) == expected + + @pytest.mark.parametrize('filters', [{}, set()]) def test_wrong_filters_type(filters): class Cls(RQLFilterClass):