Skip to content

LITE-27342 Added support for MAX_ORDERING_LENGTH_IN_QUERY and ALLOWED_ORDERING_PERMUTATIONS_IN_QUERY #63

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
May 18, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
53 changes: 52 additions & 1 deletion dj_rql/filter_cls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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`)."""
Expand Down Expand Up @@ -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

Expand All @@ -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):
Expand All @@ -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))
Expand Down Expand Up @@ -542,19 +563,29 @@ 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:
raise RQLFilterParsingError(details={
'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]
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
33 changes: 33 additions & 0 deletions tests/test_filter_cls/test_apply_filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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'

Expand All @@ -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
Expand Down
47 changes: 47 additions & 0 deletions tests/test_filter_cls/test_initialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down