Skip to content

Commit 29ccc89

Browse files
authored
Merge pull request #23 from cloudblue/feature/LITE-18940
LITE-18490 Added Django command for filter code generation
2 parents 9b6bc89 + b9f1acc commit 29ccc89

File tree

13 files changed

+569
-12
lines changed

13 files changed

+569
-12
lines changed

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,6 @@ dist/
1313
tests/reports/
1414
.coverage
1515

16-
docs/_build
16+
docs/_build
17+
18+
_generated_filters*.py

README.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,55 @@ Notes
180180
3. Support for Choices() fields from [Django Model Utilities](https://django-model-utils.readthedocs.io/en/latest/utilities.html#choices) is added
181181

182182

183+
Helpers
184+
================================
185+
There is a Django command `generate_rql_class` to decrease development and integration efforts for filtering.
186+
This command automatically generates a filter class for a given model with all relations and all optimizations (!) to the specified depth.
187+
188+
Example
189+
-------
190+
```commandline
191+
django-admin generate_rql_class --settings=tests.dj_rf.settings tests.dj_rf.models.Publisher --depth=1 --exclude=authors,fk2
192+
```
193+
This command for the model `Publisher` from tests package will produce the following output to stdout:
194+
```python
195+
from tests.dj_rf.models import Publisher
196+
197+
from dj_rql.filter_cls import RQLFilterClass
198+
from dj_rql.qs import NPR, NSR
199+
200+
201+
class PublisherFilters(RQLFilterClass):
202+
MODEL = Publisher
203+
SELECT = True
204+
EXCLUDE_FILTERS = ['authors', 'fk2']
205+
FILTERS = [
206+
{
207+
"filter": "id",
208+
"ordering": True,
209+
"search": False
210+
},
211+
{
212+
"filter": "name",
213+
"ordering": True,
214+
"search": True
215+
},
216+
{
217+
"namespace": "fk1",
218+
"filters": [
219+
{
220+
"filter": "id",
221+
"ordering": True,
222+
"search": False
223+
}
224+
],
225+
"qs": NSR('fk1')
226+
}
227+
]
228+
229+
```
230+
231+
183232
Django Rest Framework Extensions
184233
================================
185234
1. Pagination (limit, offset)

dj_rql/filter_cls.py

Lines changed: 112 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,12 @@
2828
from dj_rql.exceptions import RQLFilterLookupError, RQLFilterParsingError, RQLFilterValueError
2929
from dj_rql.openapi import RQLFilterClassSpecification
3030
from dj_rql.parser import RQLParser
31-
from dj_rql.qs import Annotation
31+
from dj_rql.qs import Annotation, NPR, NSR
3232
from dj_rql.transformer import RQLToDjangoORMTransformer
3333

34-
from django.db.models import Model, Q
34+
from django.db.models import ForeignKey, ManyToManyField, Model, OneToOneField, OneToOneRel, Q
3535
from django.utils.dateparse import parse_date, parse_datetime
36+
from django.utils.functional import cached_property
3637

3738
from lark.exceptions import LarkError
3839

@@ -1040,8 +1041,7 @@ class AutoRQLFilterClass(RQLFilterClass):
10401041
"""This class will collect all simple model fields except the ones in this field."""
10411042

10421043
def _get_init_filters(self):
1043-
described_filters = tuple(self.FILTERS) if self.FILTERS else ()
1044-
1044+
described_filters = self._described_filters
10451045
filters = tuple(
10461046
{
10471047
'filter': f.name,
@@ -1057,3 +1057,111 @@ def _get_init_filters(self):
10571057
)
10581058

10591059
return described_filters + filters
1060+
1061+
@cached_property
1062+
def _described_filters(self):
1063+
return tuple(self.FILTERS) if self.FILTERS else ()
1064+
1065+
1066+
class NestedAutoRQLFilterClass(AutoRQLFilterClass):
1067+
"""
1068+
Filter class that automatically collects filters for all model fields with
1069+
specified depth for related models.
1070+
"""
1071+
SELECT = True
1072+
1073+
DEPTH = 1
1074+
"""
1075+
Specifies how deep model relations will be traversed.
1076+
If `DEPTH = 0` this class behaves as `AutoRQLFilterClass`.
1077+
"""
1078+
1079+
def _get_init_filters(self):
1080+
if self.DEPTH == 0:
1081+
return super()._get_init_filters()
1082+
1083+
depth = 0
1084+
global_namespace = []
1085+
iterator = [(self.MODEL, global_namespace, None, None)]
1086+
1087+
while depth <= self.DEPTH and iterator:
1088+
iterator = self._iter_models_to_get_filters(depth, iterator)
1089+
depth += 1
1090+
1091+
return self._described_filters + tuple(global_namespace)
1092+
1093+
def _iter_models_to_get_filters(self, depth, iterator):
1094+
related_models = []
1095+
1096+
for model_data in iterator:
1097+
related_models.extend(self._iter_model_to_get_filters(depth, model_data))
1098+
1099+
return related_models
1100+
1101+
def _iter_model_to_get_filters(self, depth, model_data):
1102+
model, namespace, circular_related_name, prefix = model_data
1103+
through_models = set()
1104+
model_related_models = []
1105+
1106+
for field in model._meta.get_fields():
1107+
rel_f_name = self._get_relative_field_name(field, circular_related_name, prefix)
1108+
if not rel_f_name:
1109+
continue
1110+
1111+
if field.is_relation:
1112+
if self._is_through_field(field):
1113+
through_models.add(field.through)
1114+
1115+
relation_data = self._add_relation_to_iterated_models(depth, field, namespace)
1116+
model_related_models.append(relation_data + (rel_f_name,))
1117+
continue
1118+
1119+
namespace.append({
1120+
'filter': field.name,
1121+
'ordering': True,
1122+
'search': FilterTypes.field_filter_type(field) == FilterTypes.STRING,
1123+
})
1124+
1125+
return [i for i in model_related_models if i[0] not in through_models]
1126+
1127+
def _add_relation_to_iterated_models(self, depth, field, namespace):
1128+
if isinstance(field, (ForeignKey, ManyToManyField)):
1129+
circular_related_name = field.remote_field.name
1130+
else:
1131+
circular_related_name = field.field.name
1132+
1133+
namespace_filters = []
1134+
if depth < self.DEPTH:
1135+
namespace.append({
1136+
'namespace': field.name,
1137+
'filters': namespace_filters,
1138+
'qs': self._get_field_optimization(field),
1139+
})
1140+
1141+
return field.related_model, namespace_filters, circular_related_name
1142+
1143+
def _get_relative_field_name(self, field, circular_related_name, prefix):
1144+
field_name = field.name
1145+
if circular_related_name and field_name == circular_related_name:
1146+
# This is needed to avoid circular dependencies
1147+
return
1148+
1149+
rel_f_name = '.'.join((prefix, field_name)) if prefix else field_name
1150+
if rel_f_name in self.EXCLUDE_FILTERS or rel_f_name in self._described_filters:
1151+
return
1152+
1153+
return rel_f_name
1154+
1155+
def _get_field_optimization(self, field):
1156+
if not self.SELECT:
1157+
return
1158+
1159+
if isinstance(field, (ForeignKey, OneToOneField, OneToOneRel)):
1160+
return NSR(field.name)
1161+
1162+
if not self._is_through_field(field):
1163+
return NPR(field.name)
1164+
1165+
@staticmethod
1166+
def _is_through_field(field):
1167+
return getattr(field, 'through', None)

dj_rql/management/__init__.py

Whitespace-only changes.

dj_rql/management/commands/__init__.py

Whitespace-only changes.
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
#
2+
# Copyright © 2021 Ingram Micro Inc. All rights reserved.
3+
#
4+
5+
import json
6+
import re
7+
8+
from dj_rql.filter_cls import NestedAutoRQLFilterClass
9+
10+
from django.core.management import BaseCommand
11+
from django.db.models import ForeignKey, OneToOneField, OneToOneRel
12+
from django.utils.module_loading import import_string
13+
14+
15+
TEMPLATE = """from {model_package} import {model_name}
16+
17+
from dj_rql.filter_cls import RQLFilterClass
18+
{optimizations_import}
19+
20+
class {model_name}Filters(RQLFilterClass):
21+
MODEL = {model_name}
22+
SELECT = {select_flag}
23+
EXCLUDE_FILTERS = {exclusions}
24+
FILTERS = {filters}
25+
"""
26+
27+
28+
class Command(BaseCommand):
29+
help = (
30+
'Automatically generates a filter class for a model '
31+
'with all relations to the specified depth.'
32+
)
33+
34+
def add_arguments(self, parser):
35+
parser.add_argument(
36+
'model',
37+
nargs=1,
38+
type=str,
39+
help='Importable model location string.',
40+
)
41+
parser.add_argument(
42+
'-d',
43+
'--depth',
44+
type=int,
45+
default=1,
46+
help='Max depth of traversed model relations.',
47+
)
48+
parser.add_argument(
49+
'-s',
50+
'--select',
51+
action='store_true',
52+
default=True,
53+
help='Flag to include QuerySet optimizations: true by default.',
54+
)
55+
parser.add_argument(
56+
'-e',
57+
'--exclude',
58+
type=str,
59+
help='List of coma separated filter names or namespace to be excluded from generation.',
60+
)
61+
62+
def handle(self, *args, **options):
63+
model_import = options['model'][0]
64+
model = import_string(model_import)
65+
is_select = options['select']
66+
exclusions = options['exclude'].split(',') if options['exclude'] else []
67+
68+
class Cls(NestedAutoRQLFilterClass):
69+
MODEL = model
70+
DEPTH = options['depth']
71+
SELECT = is_select
72+
EXCLUDE_FILTERS = exclusions
73+
74+
def _get_init_filters(self):
75+
self.init_filters = super()._get_init_filters()
76+
77+
self.DEPTH = 0
78+
self.SELECT = False
79+
return super()._get_init_filters()
80+
81+
def _get_field_optimization(self, field):
82+
if not self.SELECT:
83+
return
84+
85+
if isinstance(field, (ForeignKey, OneToOneField, OneToOneRel)):
86+
return "NSR('{0}')".format(field.name)
87+
88+
if not self._is_through_field(field):
89+
return "NPR('{0}')".format(field.name)
90+
91+
filters = Cls(model._default_manager.all()).init_filters
92+
filters_str = json.dumps(filters, sort_keys=False, indent=4).replace(
93+
'"ordering": true', '"ordering": True',
94+
).replace(
95+
'"ordering": false', '"ordering": False',
96+
).replace(
97+
'"search": true', '"search": True',
98+
).replace(
99+
'"search": false', '"search": False',
100+
).replace(
101+
'"qs": null', '"qs": None',
102+
)
103+
104+
filters_str = re.sub(r"\"((NPR|NSR)\('\w+?'\))\"", r'\1', filters_str)
105+
106+
model_package, model_name = model_import.rsplit('.', 1)
107+
code = TEMPLATE.format(
108+
model_package=model_package,
109+
model_name=model_name,
110+
filters=filters_str,
111+
select_flag='True' if is_select else 'False',
112+
optimizations_import='from dj_rql.qs import NPR, NSR\n' if is_select else '',
113+
exclusions=exclusions,
114+
)
115+
116+
return code

docs/reference.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@ Filter classes
7575
:members:
7676

7777

78+
.. autoclass:: dj_rql.filter_cls.NestedAutoRQLFilterClass
79+
:members:
80+
81+
7882
DB optimization
7983
---------------
8084

setup.cfg

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@
22
test = pytest
33

44
[flake8]
5-
exclude = .idea,.git,venv*/,.eggs/,*.egg-info
5+
exclude = .idea,.git,venv*/,.eggs/,*.egg-info,_generated_filters*.py
66
max-line-length = 100
77
show-source = True
88
ignore = W605
99

1010
[tool:pytest]
11-
addopts = --create-db --nomigrations --junitxml=tests/reports/out.xml --cov=dj_rql --cov-report xml:tests/reports/coverage.xml
11+
addopts = --show-capture=no --create-db --nomigrations --junitxml=tests/reports/out.xml --cov=dj_rql --cov-report xml:tests/reports/coverage.xml
1212
DJANGO_SETTINGS_MODULE = tests.dj_rf.settings

0 commit comments

Comments
 (0)