├── .github
├── FUNDING.yml
└── workflows
│ └── main.yml
├── .gitignore
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── django_restql
├── __init__.py
├── exceptions.py
├── fields.py
├── mixins.py
├── operations.py
├── parser.py
├── serializers.py
└── settings.py
├── docs
├── CNAME
├── extra.css
├── img
│ ├── favicon.svg
│ ├── github.svg
│ └── icon.svg
├── index.md
├── license.md
├── mutating_data.md
├── querying_data.md
└── settings.md
├── mkdocs.yml
├── requirements.txt
├── runtests.py
├── setup.py
├── tests
├── __init__.py
├── manage.py
├── settings.py
├── test_views.py
├── testapp
│ ├── apps.py
│ ├── models.py
│ ├── serializers.py
│ └── views.py
└── urls.py
└── tox.ini
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | patreon: yezyilomo
2 | open_collective: django-restql
3 |
--------------------------------------------------------------------------------
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 | pull_request:
8 |
9 | jobs:
10 | build:
11 | runs-on: ubuntu-22.04
12 | strategy:
13 | matrix:
14 | python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"]
15 |
16 | steps:
17 | - uses: actions/checkout@v4
18 | - name: Set up Python ${{ matrix.python-version }}
19 | uses: actions/setup-python@v5
20 | with:
21 | python-version: ${{ matrix.python-version }}
22 | - name: Install dependencies
23 | run: |
24 | python -m pip install --upgrade pip
25 | pip install tox tox-gh-actions
26 | - name: Test with tox
27 | run: tox
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 |
2 | # Created by https://www.gitignore.io/api/linux,python,visualstudiocode
3 | # Edit at https://www.gitignore.io/?templates=linux,python,visualstudiocode
4 |
5 | ### Linux ###
6 | *~
7 |
8 | # temporary files which can be created if a process still has a handle open of a deleted file
9 | .fuse_hidden*
10 |
11 | # KDE directory preferences
12 | .directory
13 |
14 | # Linux trash folder which might appear on any partition or disk
15 | .Trash-*
16 |
17 | # .nfs files are created when an open file is removed but is still being accessed
18 | .nfs*
19 |
20 | ### Python ###
21 | # Byte-compiled / optimized / DLL files
22 | __pycache__/
23 | *.py[cod]
24 | *$py.class
25 |
26 | # C extensions
27 | *.so
28 |
29 | # Distribution / packaging
30 | .Python
31 | build/
32 | develop-eggs/
33 | dist/
34 | downloads/
35 | eggs/
36 | .eggs/
37 | lib/
38 | lib64/
39 | parts/
40 | sdist/
41 | var/
42 | wheels/
43 | pip-wheel-metadata/
44 | share/python-wheels/
45 | *.egg-info/
46 | .installed.cfg
47 | *.egg
48 | MANIFEST
49 |
50 | # PyInstaller
51 | # Usually these files are written by a python script from a template
52 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
53 | *.manifest
54 | *.spec
55 |
56 | # Installer logs
57 | pip-log.txt
58 | pip-delete-this-directory.txt
59 |
60 | # Unit test / coverage reports
61 | htmlcov/
62 | .tox/
63 | .nox/
64 | .coverage
65 | .coverage.*
66 | .cache
67 | nosetests.xml
68 | coverage.xml
69 | *.cover
70 | .hypothesis/
71 | .pytest_cache/
72 |
73 | # Translations
74 | *.mo
75 | *.pot
76 |
77 | # Django stuff:
78 | *.log
79 | local_settings.py
80 | db.sqlite3
81 |
82 | # Flask stuff:
83 | instance/
84 | .webassets-cache
85 |
86 | # Scrapy stuff:
87 | .scrapy
88 |
89 | # Sphinx documentation
90 | docs/_build/
91 |
92 | # PyBuilder
93 | target/
94 |
95 | # Jupyter Notebook
96 | .ipynb_checkpoints
97 |
98 | # IPython
99 | profile_default/
100 | ipython_config.py
101 |
102 | # pyenv
103 | .python-version
104 |
105 | # celery beat schedule file
106 | celerybeat-schedule
107 |
108 | # SageMath parsed files
109 | *.sage.py
110 |
111 | # Environments
112 | .env
113 | .venv
114 | env/
115 | venv/
116 | ENV/
117 | env.bak/
118 | venv.bak/
119 |
120 | # Spyder project settings
121 | .spyderproject
122 | .spyproject
123 |
124 | # Rope project settings
125 | .ropeproject
126 |
127 | # mkdocs documentation
128 | /site
129 |
130 | # mypy
131 | .mypy_cache/
132 | .dmypy.json
133 | dmypy.json
134 |
135 | # Pyre type checker
136 | .pyre/
137 |
138 | ### Python Patch ###
139 | .venv/
140 |
141 | ### VisualStudioCode ###
142 | .vscode/*
143 |
144 |
145 | ### VisualStudioCode Patch ###
146 | # Ignore all local history of files
147 | .history
148 |
149 | # End of https://www.gitignore.io/api/linux,python,visualstudiocode
150 |
151 | # Custom
152 | .DS_Store
153 | .idea
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | :+1::tada: First off, thanks for taking the time to contribute! :tada::+1:
2 |
3 | The following is a set of guidelines for contributing to **django-restql**. These are mostly guidelines, not rules. Use your best judgment, and feel free to propose changes to this guidelines in a pull request.
4 |
5 | ## How Can I Contribute?
6 |
7 | **Working on your first Pull Request?** You can learn how from this *free* series [How to Contribute to an Open Source Project on GitHub](https://egghead.io/series/how-to-contribute-to-an-open-source-project-on-github)
8 |
9 | If you are experienced you can go direct fork the repository on GitHub and send a pull request, or file an issue ticket at the issue tracker. For general help and questions you can email me at [yezileliilomo@hotmail.com](mailto:yezileliilomo@hotmail.com).
10 |
11 | ## Styleguides
12 |
13 | ### Git Commit Messages
14 |
15 | * Use the present tense ("Add feature" not "Added feature")
16 | * Use the imperative mood ("Move cursor to..." not "Moves cursor to...")
17 | * Limit the first line to 72 characters or less
18 | * Reference issues and pull requests liberally after the first line
19 | * Consider starting the commit message with an applicable emoji like
20 | * :art: `:art:` when improving the format/structure of the code
21 | * :memo: `:memo:` when writing docs
22 | * :bug: `:bug:` when fixing a bug
23 | * :fire: `:fire:` when removing code or files
24 | * :sparkles: when introducing new feature
25 | * :green_heart: `:green_heart:` when fixing the CI build
26 | * :white_check_mark: `:white_check_mark:` when adding tests
27 | * :lock: `:lock:` when dealing with security
28 | * :arrow_up: `:arrow_up:` when upgrading dependencies
29 | * :arrow_down: `:arrow_down:` when downgrading dependencies
30 | * For more emojis visit [gitmoji](https://gitmoji.dev/)
31 |
32 | ### Python Styleguide
33 |
34 | All Python code must adhere to [PEP 8](https://www.python.org/dev/peps/pep-0008/).
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 Yezy Ilomo
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # [ Django RESTQL](https://yezyilomo.github.io/django-restql)
2 |
3 | 
4 | [](https://pypi.org/project/django-restql/)
5 | [](https://pypi.org/project/django-restql/)
6 | [](https://pypi.org/project/django-restql/)
7 |
8 | [](https://pepy.tech/project/django-restql)
9 | [](https://pepy.tech/project/django-restql)
10 | [](https://pepy.tech/project/django-restql)
11 |
12 |
13 | **Django RESTQL** is a python library which allows you to turn your API made with **Django REST Framework(DRF)** into a GraphQL like API. With **Django RESTQL** you will be able to
14 |
15 | * Send a query to your API and get exactly what you need, nothing more and nothing less.
16 |
17 | * Control the data you get, not the server.
18 |
19 | * Get predictable results, since you control what you get from the server.
20 |
21 | * Get nested resources in a single request.
22 |
23 | * Avoid Over-fetching and Under-fetching of data.
24 |
25 | * Write(create & update) nested data of any level in a single request.
26 |
27 | Isn't it cool?.
28 |
29 | Want to see how this library is making all that possible?
30 |
31 | Check out the full documentation at [https://yezyilomo.github.io/django-restql](https://yezyilomo.github.io/django-restql)
32 |
33 | Or try a live demo on [Django RESTQL Playground](https://django-restql-playground.yezyilomo.me)
34 |
35 |
36 | ## Requirements
37 | * Python >= 3.6
38 | * Django >= 1.11
39 | * Django REST Framework >= 3.5
40 |
41 |
42 | ## Installing
43 | ```py
44 | pip install django-restql
45 | ```
46 |
47 |
48 | ## Getting Started
49 | Using **Django RESTQL** to query data is very simple, you just have to inherit the `DynamicFieldsMixin` class when defining a serializer that's all.
50 | ```py
51 | from rest_framework import serializers
52 | from django.contrib.auth.models import User
53 | from django_restql.mixins import DynamicFieldsMixin
54 |
55 |
56 | class UserSerializer(DynamicFieldsMixin, serializer.ModelSerializer):
57 | class Meta:
58 | model = User
59 | fields = ['id', 'username', 'email']
60 | ```
61 |
62 | **Django RESTQL** handle all requests with a `query` parameter, this parameter is the one used to pass all fields to be included/excluded in a response. For example to select `id` and `username` fields from User model, send a request with a ` query` parameter as shown below.
63 |
64 | `GET /users/?query={id, username}`
65 | ```js
66 | [
67 | {
68 | "id": 1,
69 | "username": "yezyilomo"
70 | },
71 | ...
72 | ]
73 | ```
74 |
75 | **Django RESTQL** support querying both flat and nested resources, so you can expand or query nested fields at any level as defined on a serializer. In an example below we have `location` as a nested field on User model.
76 |
77 | ```py
78 | from rest_framework import serializers
79 | from django.contrib.auth.models import User
80 | from django_restql.mixins import DynamicFieldsMixin
81 |
82 | from app.models import GroupSerializer, LocationSerializer
83 |
84 |
85 | class LocationSerializer(DynamicFieldsMixin, serializer.ModelSerializer):
86 | class Meta:
87 | model = Location
88 | fields = ['id', 'country', 'city', 'street']
89 |
90 |
91 | class UserSerializer(DynamicFieldsMixin, serializer.ModelSerializer):
92 | location = LocationSerializer(many=False, read_only=True)
93 | class Meta:
94 | model = User
95 | fields = ['id', 'username', 'email', 'location']
96 | ```
97 |
98 | If you want only `country` and `city` fields on a `location` field when retrieving users here is how you can do it
99 |
100 | `GET /users/?query={id, username, location{country, city}}`
101 | ```js
102 | [
103 | {
104 | "id": 1,
105 | "username": "yezyilomo",
106 | "location": {
107 | "contry": "Tanzania",
108 | "city": "Dar es salaam"
109 | }
110 | },
111 | ...
112 | ]
113 | ```
114 |
115 | You can even rename your fields when querying data, In an example below the field `location` is renamed to `address`
116 |
117 | `GET /users/?query={id, username, address: location{country, city}}`
118 | ```js
119 | [
120 | {
121 | "id": 1,
122 | "username": "yezyilomo",
123 | "address": {
124 | "contry": "Tanzania",
125 | "city": "Dar es salaam"
126 | }
127 | },
128 | ...
129 | ]
130 | ```
131 |
132 |
133 | ## [Documentation :pencil:](https://yezyilomo.github.io/django-restql)
134 | You can do a lot with **Django RESTQL** apart from querying data, like
135 | - Rename fields
136 | - Restrict some fields on nested fields
137 | - Define self referencing nested fields
138 | - Optimize data fetching on nested fields
139 | - Data filtering and pagination by using query arguments
140 | - Data mutation(Create and update nested data of any level in a single request)
141 |
142 | Full documentation for this project is available at [https://yezyilomo.github.io/django-restql](https://yezyilomo.github.io/django-restql), you are advised to read it inorder to utilize this library to the fullest.
143 |
144 |
145 | ## [Django RESTQL Play Ground](https://django-restql-playground.yezyilomo.me)
146 | [**Django RESTQL Play Ground**](https://django-restql-playground.yezyilomo.me) is a graphical, interactive, in-browser tool which you can use to test **Django RESTQL** features like data querying, mutations etc to get the idea of how the library works before installing it. It's more like a [**live demo**](https://django-restql-playground.yezyilomo.me) for **Django RESTQL**, it's available at [https://django-restql-playground.yezyilomo.me](https://django-restql-playground.yezyilomo.me)
147 |
148 |
149 | ## Running Tests
150 | `python runtests.py`
151 |
152 |
153 | ## Credits
154 | * Implementation of this library is based on the idea behind [GraphQL](https://graphql.org/).
155 | * My intention is to extend the capability of [drf-dynamic-fields](https://github.com/dbrgn/drf-dynamic-fields) library to support more functionalities like allowing to query nested fields both flat and iterable at any level and allow writing on nested fields while maintaining simplicity.
156 |
157 |
158 | ## Contributing [](http://makeapullrequest.com)
159 |
160 | We welcome all contributions. Please read our [CONTRIBUTING.md](https://github.com/yezyilomo/django-restql/blob/master/CONTRIBUTING.md) first. You can submit any ideas as [pull requests](https://github.com/yezyilomo/django-restql/pulls) or as [GitHub issues](https://github.com/yezyilomo/django-restql/issues). If you'd like to improve code, check out the [Code Style Guide](https://github.com/yezyilomo/django-restql/blob/master/CONTRIBUTING.md#styleguides) and have a good time!.
161 |
--------------------------------------------------------------------------------
/django_restql/__init__.py:
--------------------------------------------------------------------------------
1 | __title__ = 'Django RESTQL'
2 | __description__ = 'Turn your API made with Django REST Framework(DRF) into a GraphQL like API.'
3 | __url__ = 'https://yezyilomo.github.io/django-restql'
4 | __version__ = '0.16.2'
5 | __author__ = 'Yezy Ilomo'
6 | __author_email__ = 'yezileliilomo@hotmail.com'
7 | __license__ = 'MIT'
8 | __copyright__ = 'Copyright 2019 Yezy Ilomo'
9 |
10 | # Version synonym
11 | VERSION = __version__
12 |
--------------------------------------------------------------------------------
/django_restql/exceptions.py:
--------------------------------------------------------------------------------
1 | class DjangoRESTQLException(Exception):
2 | """Base class for exceptions in this package."""
3 |
4 |
5 | class InvalidOperation(DjangoRESTQLException):
6 | """Invalid Operation Exception."""
7 |
8 |
9 | class FieldNotFound(DjangoRESTQLException):
10 | """Field Not Found Exception."""
11 |
12 |
13 | class QueryFormatError(DjangoRESTQLException):
14 | """Invalid Query Format."""
15 |
--------------------------------------------------------------------------------
/django_restql/fields.py:
--------------------------------------------------------------------------------
1 | try:
2 | from django.utils.decorators import classproperty
3 | except ImportError:
4 | from django.utils.functional import classproperty
5 | from django.db.models.fields.related import ManyToOneRel
6 |
7 | from rest_framework.fields import (
8 | DictField, ListField, SkipField, Field, empty
9 | )
10 | from rest_framework.serializers import (
11 | ListSerializer, PrimaryKeyRelatedField,
12 | SerializerMethodField, ValidationError
13 | )
14 |
15 | from .parser import Query
16 | from .exceptions import InvalidOperation
17 | from .operations import ADD, CREATE, REMOVE, UPDATE
18 |
19 | CREATE_OPERATIONS = (ADD, CREATE)
20 | UPDATE_OPERATIONS = (ADD, CREATE, REMOVE, UPDATE)
21 |
22 | ALL_RELATED_OBJS = '__all__'
23 |
24 |
25 | class DynamicSerializerMethodField(SerializerMethodField):
26 | def to_representation(self, value):
27 | method = getattr(self.parent, self.method_name)
28 | is_parsed_query_available = (
29 | hasattr(self.parent, "restql_nested_parsed_queries") and
30 | self.field_name in self.parent.restql_nested_parsed_queries
31 | )
32 |
33 | if is_parsed_query_available:
34 | parsed_query = self.parent.restql_nested_parsed_queries[self.field_name]
35 | else:
36 | # Include all fields
37 | parsed_query = Query(
38 | field_name=None,
39 | included_fields=["*"],
40 | excluded_fields=[],
41 | aliases={},
42 | arguments={}
43 | )
44 | return method(value, parsed_query)
45 |
46 |
47 | class BaseRESTQLNestedField(object):
48 | def to_internal_value(self, data):
49 | raise NotImplementedError('`to_internal_value()` must be implemented.')
50 |
51 |
52 | def BaseNestedFieldSerializerFactory(
53 | *args,
54 | accept_pk=False,
55 | accept_pk_only=False,
56 | allow_remove_all=False,
57 | create_ops=CREATE_OPERATIONS,
58 | update_ops=UPDATE_OPERATIONS,
59 | serializer_class=None,
60 | **kwargs):
61 | many = kwargs.get("many", False)
62 | partial = kwargs.get("partial", None)
63 |
64 | assert not (
65 | many and (accept_pk or accept_pk_only)
66 | ), (
67 | "May not set both `many=True` and `accept_pk=True` "
68 | "or `accept_pk_only=True`"
69 | "(accept_pk and accept_pk_only applies to foreign key relation only)."
70 | )
71 |
72 | assert not (
73 | accept_pk and accept_pk_only
74 | ), "May not set both `accept_pk=True` and `accept_pk_only=True`"
75 |
76 | assert not (
77 | allow_remove_all and not many
78 | ), (
79 | "`allow_remove_all=True` can only be applied to many related "
80 | "nested fields, ensure the kwarg `many=True` is set."
81 | )
82 |
83 | def join_words(words, many='are', single='is'):
84 | word_list = ["`" + word + "`" for word in words]
85 |
86 | if len(words) > 1:
87 | sentence = " & ".join([", ".join(word_list[:-1]), word_list[-1]])
88 | return "%s %s" % (many, sentence)
89 | elif len(words) == 1:
90 | return "%s %s" % (single, word_list[0])
91 | return "%s %s" % (single, "[]")
92 |
93 | if not set(create_ops).issubset(set(CREATE_OPERATIONS)):
94 | msg = (
95 | "Invalid create operation(s) at `%s`, Supported operations " +
96 | join_words(CREATE_OPERATIONS)
97 | ) % "create_ops=%s" % create_ops
98 | raise InvalidOperation(msg)
99 |
100 | if not set(update_ops).issubset(set(UPDATE_OPERATIONS)):
101 | msg = (
102 | "Invalid update operation(s) at `%s`, Supported operations " +
103 | join_words(UPDATE_OPERATIONS)
104 | ) % "update_ops=%s" % update_ops
105 | raise InvalidOperation(msg)
106 |
107 | if serializer_class == "self":
108 | # We have a self referencing serializer so the serializer
109 | # class is not available at the moment, we return None
110 | return None
111 |
112 | class BaseNestedField(BaseRESTQLNestedField):
113 | @classproperty
114 | def serializer_class(cls):
115 | # Return original nested serializer
116 | return serializer_class
117 |
118 | def is_partial(self, default):
119 | # Check if partial kwarg is passed if not return the default
120 | if partial is not None:
121 | return partial
122 | return default
123 |
124 | class BaseNestedFieldListSerializer(ListSerializer, BaseNestedField):
125 | def run_pk_list_validation(self, pks):
126 | ListField().run_validation(pks)
127 | queryset = self.child.Meta.model.objects.all()
128 | PrimaryKeyRelatedField(
129 | **self.child.validation_kwargs,
130 | queryset=queryset,
131 | many=True
132 | ).run_validation(pks)
133 |
134 | def run_data_list_validation(self, data, partial=None, operation=None):
135 | ListField().run_validation(data)
136 | model = self.parent.Meta.model
137 | rel = getattr(model, self.source).rel
138 | if isinstance(rel, ManyToOneRel):
139 | # ManyToOne Relation
140 | field_name = getattr(model, self.source).field.name
141 | child_serializer = serializer_class(
142 | **self.child.validation_kwargs,
143 | data=data,
144 | many=True,
145 | partial=partial,
146 | context={**self.context, "parent_operation": operation}
147 | )
148 |
149 | # Remove parent field(field_name) for validation purpose
150 | child_serializer.child.fields.pop(field_name, None)
151 |
152 | # Check if a serializer is valid
153 | child_serializer.is_valid(raise_exception=True)
154 | else:
155 | # ManyToMany Relation
156 | child_serializer = serializer_class(
157 | **self.child.validation_kwargs,
158 | data=data,
159 | many=True,
160 | partial=partial,
161 | context={**self.context, "parent_operation": operation}
162 | )
163 |
164 | # Check if a serializer is valid
165 | child_serializer.is_valid(raise_exception=True)
166 |
167 | def run_add_list_validation(self, data):
168 | self.run_pk_list_validation(data)
169 |
170 | def run_create_list_validation(self, data):
171 | self.run_data_list_validation(
172 | data,
173 | partial=self.is_partial(False),
174 | operation=CREATE
175 | )
176 |
177 | def run_remove_list_validation(self, data):
178 | if data == ALL_RELATED_OBJS:
179 | if not allow_remove_all:
180 | msg = (
181 | "Using `%s` value on `%s` operation is disabled"
182 | % (ALL_RELATED_OBJS, REMOVE)
183 | )
184 | raise ValidationError(msg, code="not_allowed")
185 | else:
186 | self.run_pk_list_validation(data)
187 |
188 | def run_update_list_validation(self, data):
189 | DictField().run_validation(data)
190 | pks = list(data.keys())
191 | self.run_pk_list_validation(pks)
192 | values = list(data.values())
193 | self.run_data_list_validation(
194 | values,
195 | partial=self.is_partial(True),
196 | operation=UPDATE
197 | )
198 |
199 | def run_data_validation(self, data, allowed_ops):
200 | DictField().run_validation(data)
201 |
202 | operation_2_validation_method = {
203 | ADD: self.run_add_list_validation,
204 | CREATE: self.run_create_list_validation,
205 | REMOVE: self.run_remove_list_validation,
206 | UPDATE: self.run_update_list_validation,
207 | }
208 |
209 | allowed_operation_2_validation_method = {
210 | operation: operation_2_validation_method[operation]
211 | for operation in allowed_ops
212 | }
213 |
214 | for operation, values in data.items():
215 | try:
216 | allowed_operation_2_validation_method[operation](values)
217 | except ValidationError as e:
218 | detail = {operation: e.detail}
219 | code = e.get_codes()
220 | raise ValidationError(detail, code=code) from None
221 | except KeyError:
222 | msg = (
223 | "`%s` is not a valid operation, valid operations(s) "
224 | "for this request %s"
225 | % (operation, join_words(allowed_ops))
226 | )
227 | code = 'invalid_operation'
228 | raise ValidationError(msg, code=code) from None
229 |
230 | def to_internal_value(self, data):
231 | if self.child.root.instance is None:
232 | parent_operation = self.context.get("parent_operation")
233 | if parent_operation == "update":
234 | # Definitely an update
235 | self.run_data_validation(data, update_ops)
236 | else:
237 | self.run_data_validation(data, create_ops)
238 | else:
239 | # Definitely an update
240 | self.run_data_validation(data, update_ops)
241 | return data
242 |
243 | def __repr__(self):
244 | return (
245 | "BaseNestedField(%s, many=True)" %
246 | (serializer_class.__name__, )
247 | )
248 |
249 | class BaseNestedFieldSerializer(serializer_class, BaseNestedField):
250 |
251 | # might be used before `to_internal_value` method is called
252 | # so we're creating this property to make sure it's available
253 | # as long as the class is created
254 | is_replaceable = accept_pk_only or accept_pk
255 |
256 | class Meta(serializer_class.Meta):
257 | list_serializer_class = BaseNestedFieldListSerializer
258 |
259 | def run_validation(self, data):
260 | # Run `to_internal_value` only nothing more
261 | # This is needed only on DRF 3.8.x due to a bug on it
262 | # This function can be removed on other supported DRF versions
263 | # i.e v3.7 v3.9 v3.10 etc doesn't need this function
264 | return self.to_internal_value(data)
265 |
266 | def run_pk_validation(self, pk):
267 | queryset = self.Meta.model.objects.all()
268 | validator = PrimaryKeyRelatedField(
269 | **self.validation_kwargs,
270 | queryset=queryset,
271 | many=False
272 | )
273 | # If valid return object instead of pk
274 | return validator.run_validation(pk)
275 |
276 | def run_data_validation(self, data):
277 | parent_operation = self.context.get("parent_operation")
278 |
279 | child_serializer = serializer_class(
280 | **self.validation_kwargs,
281 | data=data,
282 | partial=self.is_partial(
283 | # Use the partial value passed, if it's not passed
284 | # Use the one from the top level parent
285 | True if parent_operation == UPDATE else False
286 | ),
287 | context=self.context
288 | )
289 |
290 | # Set parent to a child serializer
291 | child_serializer.parent = self.parent
292 |
293 | # Check if a serializer is valid
294 | child_serializer.is_valid(raise_exception=True)
295 |
296 | # return data to be passed to a nested serializer,
297 | # don't be tempted to return child_serializer.validated_data
298 | # cuz it changes representation of some values for instance
299 | # pks gets converted into objects
300 | return data
301 |
302 | def to_internal_value(self, data):
303 | required = kwargs.get('required', True)
304 | default = kwargs.get('default', empty)
305 |
306 | if data == empty:
307 | # Implementation under this block is made
308 | # according to DRF behaviour to other normal fields
309 | # For more details see
310 | # https://www.django-rest-framework.org/api-guide/fields/#required
311 | # https://www.django-rest-framework.org/api-guide/fields/#default
312 | # https://www.django-rest-framework.org/api-guide/fields/#allow_null
313 | if self.root.partial or not required:
314 | # Skip the field because the update is partial
315 | # or the field is not required(optional)
316 | raise SkipField()
317 | elif required:
318 | if default == empty:
319 | raise ValidationError(
320 | "This field is required.",
321 | code='required'
322 | )
323 | else:
324 | # Use the default value
325 | data = default
326 |
327 | if accept_pk_only:
328 | return self.run_pk_validation(data)
329 | elif accept_pk:
330 | if isinstance(data, dict):
331 | self.is_replaceable = False
332 | return self.run_data_validation(data)
333 | else:
334 | return self.run_pk_validation(data)
335 | return self.run_data_validation(data)
336 |
337 | def __repr__(self):
338 | return (
339 | "BaseNestedField(%s, many=False)" %
340 | (serializer_class.__name__, )
341 | )
342 |
343 | return {
344 | "serializer_class": BaseNestedFieldSerializer,
345 | "list_serializer_class": BaseNestedFieldListSerializer,
346 | "args": args,
347 | "kwargs": kwargs
348 | }
349 |
350 |
351 | class TemporaryNestedField(Field, BaseRESTQLNestedField):
352 | """
353 | This is meant to be used temporarily when 'self' is
354 | passed as the first arg to `NestedField`
355 | """
356 |
357 | def __init__(
358 | self, NestedField, *args,
359 | field_args=None, field_kwargs=None, **kwargs):
360 | self.field_args = field_args
361 | self.field_kwargs = field_kwargs
362 | self.NestedField = NestedField
363 | super().__init__(*args, **kwargs)
364 |
365 | def get_actual_nested_field(self, serializer_class):
366 | # Replace "self" with the actual parent serializer class
367 | self.field_kwargs.update({
368 | "serializer_class": serializer_class
369 | })
370 |
371 | # Reproduce the actual field
372 | return self.NestedField(
373 | *self.field_args,
374 | **self.field_kwargs
375 | )
376 |
377 |
378 | def NestedFieldWraper(*args, **kwargs):
379 | serializer_class = kwargs["serializer_class"]
380 | factory = BaseNestedFieldSerializerFactory(*args, **kwargs)
381 |
382 | if factory is None:
383 | # We have a self referencing serializer so we return
384 | # a temporary field while we are waiting for the parent
385 | # to be ready(when it's ready the parent itself will replace
386 | # this field with the actual field)
387 | return TemporaryNestedField(
388 | NestedFieldWraper,
389 | field_args=args,
390 | field_kwargs=kwargs
391 | )
392 |
393 | serializer_validation_kwargs = {**factory['kwargs']}
394 |
395 | # Remove all non validation related kwargs and
396 | # DynamicFieldsMixin kwargs from `valdation_kwargs`
397 | non_validation_related_kwargs = [
398 | 'many', 'data', 'instance', 'context', 'fields',
399 | 'exclude', 'return_pk', 'disable_dynamic_fields',
400 | 'query', 'parsed_query', 'partial'
401 | ]
402 |
403 | for kwarg in non_validation_related_kwargs:
404 | serializer_validation_kwargs.pop(kwarg, None)
405 |
406 | class NestedListSerializer(factory["list_serializer_class"]):
407 | def __repr__(self):
408 | return (
409 | "NestedField(%s, many=False)" %
410 | (serializer_class.__name__, )
411 | )
412 |
413 | class NestedSerializer(factory["serializer_class"]):
414 | # set validation related kwargs to be used on
415 | # `NestedCreateMixin` and `NestedUpdateMixin`
416 | validation_kwargs = serializer_validation_kwargs
417 |
418 | class Meta(factory["serializer_class"].Meta):
419 | list_serializer_class = NestedListSerializer
420 |
421 | def __repr__(self):
422 | return (
423 | "NestedField(%s, many=False)" %
424 | (serializer_class.__name__, )
425 | )
426 |
427 | return NestedSerializer(
428 | *factory["args"],
429 | **factory["kwargs"]
430 | )
431 |
432 |
433 | def NestedField(serializer_class, *args, **kwargs):
434 | return NestedFieldWraper(
435 | *args,
436 | **kwargs,
437 | serializer_class=serializer_class
438 | )
439 |
--------------------------------------------------------------------------------
/django_restql/mixins.py:
--------------------------------------------------------------------------------
1 | from django.http import QueryDict
2 | from django.db.models import Prefetch
3 | from django.utils.functional import cached_property
4 | from django.core.exceptions import ObjectDoesNotExist
5 | from django.db.models.fields.related import ManyToManyRel, ManyToOneRel
6 |
7 | try:
8 | from django.contrib.contenttypes.fields import GenericRel
9 | from django.contrib.contenttypes.models import ContentType
10 | except (RuntimeError, ImportError):
11 | GenericRel = None
12 | ContentType = None
13 |
14 | from rest_framework.serializers import ListSerializer, Serializer, ValidationError
15 |
16 | from .exceptions import FieldNotFound, QueryFormatError
17 | from .fields import (
18 | ALL_RELATED_OBJS,
19 | BaseRESTQLNestedField,
20 | DynamicSerializerMethodField,
21 | TemporaryNestedField,
22 | )
23 | from .operations import ADD, CREATE, REMOVE, UPDATE
24 | from .parser import Query, QueryParser
25 | from .settings import restql_settings
26 |
27 |
28 | class RequestQueryParserMixin(object):
29 | """
30 | Mixin for parsing restql query from request.
31 |
32 | NOTE: We are using `request.GET` instead of
33 | `request.query_params` because this might be
34 | called before DRF request is created(i.e from dispatch).
35 | This means `request.query_params` might not be available
36 | when this mixin is used.
37 | """
38 |
39 | @classmethod
40 | def has_restql_query_param(cls, request):
41 | query_param_name = restql_settings.QUERY_PARAM_NAME
42 | return query_param_name in request.GET
43 |
44 | @classmethod
45 | def get_parsed_restql_query_from_req(cls, request):
46 | if hasattr(request, "parsed_restql_query"):
47 | # Use cached parsed restql query
48 | return request.parsed_restql_query
49 | raw_query = request.GET[restql_settings.QUERY_PARAM_NAME]
50 | parser = QueryParser()
51 | parsed_restql_query = parser.parse(raw_query)
52 |
53 | # Save parsed restql query to the request so that
54 | # we won't need to parse it again if needed later
55 | request.parsed_restql_query = parsed_restql_query
56 | return parsed_restql_query
57 |
58 |
59 | class QueryArgumentsMixin(RequestQueryParserMixin):
60 | """Mixin for converting query arguments into query parameters"""
61 |
62 | def get_parsed_restql_query(self, request):
63 | if self.has_restql_query_param(request):
64 | try:
65 | return self.get_parsed_restql_query_from_req(request)
66 | except (SyntaxError, QueryFormatError):
67 | # Let `DynamicFieldsMixin` handle this for a user
68 | # to get a helpful error message
69 | pass
70 |
71 | # Else include all fields
72 | query = Query(
73 | field_name=None,
74 | included_fields=["*"],
75 | excluded_fields=[],
76 | aliases={},
77 | arguments={},
78 | )
79 | return query
80 |
81 | def build_query_params(self, parsed_query, parent=None):
82 | query_params = {}
83 | prefix = ""
84 | if parent is None:
85 | query_params.update(parsed_query.arguments)
86 | else:
87 | prefix = parent + "__"
88 | for argument, value in parsed_query.arguments.items():
89 | name = prefix + argument
90 | query_params.update({name: value})
91 |
92 | for field in parsed_query.included_fields:
93 | if isinstance(field, Query):
94 | nested_query_params = self.build_query_params(
95 | field, parent=prefix + field.field_name
96 | )
97 | query_params.update(nested_query_params)
98 | return query_params
99 |
100 | def inject_query_params_in_req(self, request):
101 | parsed = self.get_parsed_restql_query(request)
102 |
103 | # Generate query params from query arguments
104 | query_params = self.build_query_params(parsed)
105 |
106 | # We are using `request.GET` instead of `request.query_params`
107 | # because at this point DRF request is not yet created so
108 | # `request.query_params` is not yet available
109 | params = request.GET.copy()
110 | params.update(query_params)
111 |
112 | # Make QueryDict immutable after updating
113 | request.GET = QueryDict(params.urlencode(), mutable=False)
114 |
115 | def dispatch(self, request, *args, **kwargs):
116 | self.inject_query_params_in_req(request)
117 | return super().dispatch(request, *args, **kwargs)
118 |
119 |
120 | class DynamicFieldsMixin(RequestQueryParserMixin):
121 | def __init__(self, *args, **kwargs):
122 | # Don't pass DynamicFieldsMixin's kwargs to the superclass
123 | self.dynamic_fields_mixin_kwargs = {
124 | "query": kwargs.pop("query", None),
125 | "parsed_query": kwargs.pop("parsed_query", None),
126 | "fields": kwargs.pop("fields", None),
127 | "exclude": kwargs.pop("exclude", None),
128 | "return_pk": kwargs.pop("return_pk", False),
129 | "disable_dynamic_fields": kwargs.pop("disable_dynamic_fields", False),
130 | }
131 |
132 | msg = "May not set both `fields` and `exclude` kwargs"
133 | assert not (
134 | self.dynamic_fields_mixin_kwargs["fields"] is not None
135 | and self.dynamic_fields_mixin_kwargs["exclude"] is not None
136 | ), msg
137 |
138 | msg = "May not set both `query` and `parsed_query` kwargs"
139 | assert not (
140 | self.dynamic_fields_mixin_kwargs["query"] is not None
141 | and self.dynamic_fields_mixin_kwargs["parsed_query"] is not None
142 | ), msg
143 |
144 | # flag to toggle using restql fields
145 | self.is_ready_to_use_dynamic_fields = False
146 |
147 | # Instantiate the superclass normally
148 | super().__init__(*args, **kwargs)
149 |
150 | def to_representation(self, instance):
151 | # Activate using restql fields
152 | self.is_ready_to_use_dynamic_fields = True
153 |
154 | if self.dynamic_fields_mixin_kwargs["return_pk"]:
155 | return instance.pk
156 | return super().to_representation(instance)
157 |
158 | @cached_property
159 | def allowed_fields(self):
160 | fields = super().fields
161 | if self.dynamic_fields_mixin_kwargs["fields"] is not None:
162 | # Drop all fields which are not specified on the `fields` kwarg.
163 | allowed = set(self.dynamic_fields_mixin_kwargs["fields"])
164 | existing = set(fields)
165 | not_allowed = existing.symmetric_difference(allowed)
166 | for field_name in not_allowed:
167 | try:
168 | fields.pop(field_name)
169 | except KeyError:
170 | msg = "Field `%s` is not found" % field_name
171 | raise FieldNotFound(msg) from None
172 |
173 | if self.dynamic_fields_mixin_kwargs["exclude"] is not None:
174 | # Drop all fields specified on the `exclude` kwarg.
175 | not_allowed = set(self.dynamic_fields_mixin_kwargs["exclude"])
176 | for field_name in not_allowed:
177 | try:
178 | fields.pop(field_name)
179 | except KeyError:
180 | msg = "Field `%s` is not found" % field_name
181 | raise FieldNotFound(msg) from None
182 | return fields
183 |
184 | @staticmethod
185 | def is_field_found(field_name, all_field_names, raise_exception=False):
186 | if field_name in all_field_names:
187 | return True
188 | else:
189 | if raise_exception:
190 | msg = "`%s` field is not found" % field_name
191 | raise ValidationError(msg, code="not_found")
192 | return False
193 |
194 | @staticmethod
195 | def is_nested_field(field_name, field, raise_exception=False):
196 | nested_classes = (Serializer, ListSerializer, DynamicSerializerMethodField)
197 | if isinstance(field, nested_classes):
198 | return True
199 | else:
200 | if raise_exception:
201 | msg = "`%s` is not a nested field" % field_name
202 | raise ValidationError(msg, code="invalid")
203 | return False
204 |
205 | @staticmethod
206 | def is_valid_alias(alias):
207 | if len(alias) > restql_settings.MAX_ALIAS_LEN:
208 | msg = (
209 | "The length of `%s` alias has exceeded "
210 | "the limit specified, which is %s characters."
211 | ) % (alias, restql_settings.MAX_ALIAS_LEN)
212 | raise ValidationError(msg, code="invalid")
213 |
214 | def rename_aliased_fields(self, aliases, all_fields):
215 | for field, alias in aliases.items():
216 | self.is_field_found(field, all_fields, raise_exception=True)
217 | self.is_valid_alias(alias)
218 | all_fields[alias] = all_fields[field]
219 | return all_fields
220 |
221 | def select_fields(self, parsed_query, all_fields):
222 | self.rename_aliased_fields(parsed_query.aliases, all_fields)
223 |
224 | # The format is [field1, field2 ...]
225 | allowed_flat_fields = []
226 |
227 | # The format is {nested_field: [sub_fields ...] ...}
228 | allowed_nested_fields = {}
229 |
230 | # The parsed_query.excluded_fields
231 | # is a list of names of excluded fields
232 | # The format is [field1, field2 ...]
233 | excluded_fields = parsed_query.excluded_fields
234 |
235 | # The parsed_query.included_fields
236 | # contains a list of allowed fields,
237 | # The format is [field, {nested_field: [sub_fields ...]} ...]
238 | included_fields = parsed_query.included_fields
239 |
240 | include_all_fields = False # Assume the * is not set initially
241 |
242 | # Go through all included fields to check if
243 | # they are all valid and to set `nested_fields`
244 | # property on parent fields for future reference
245 | for field in included_fields:
246 | if field == "*":
247 | # Include all fields but ignore `*` since
248 | # it's not an actual field(it's just a flag)
249 | include_all_fields = True
250 | continue
251 | if isinstance(field, Query):
252 | # Nested field
253 | alias = parsed_query.aliases.get(field.field_name, field.field_name)
254 |
255 | self.is_field_found(field.field_name, all_fields, raise_exception=True)
256 | self.is_nested_field(
257 | field.field_name, all_fields[field.field_name], raise_exception=True
258 | )
259 | allowed_nested_fields.update({alias: field})
260 | else:
261 | # Flat field
262 | alias = parsed_query.aliases.get(field, field)
263 | self.is_field_found(field, all_fields, raise_exception=True)
264 | allowed_flat_fields.append(alias)
265 |
266 | def get_duplicates(items):
267 | unique = []
268 | repeated = []
269 | for item in items:
270 | if item not in unique:
271 | unique.append(item)
272 | else:
273 | repeated.append(item)
274 | return repeated
275 |
276 | included_and_excluded_fields = (
277 | allowed_flat_fields + list(allowed_nested_fields.keys()) + excluded_fields
278 | )
279 |
280 | including_or_excluding_field_more_than_once = len(
281 | included_and_excluded_fields
282 | ) != len(set(included_and_excluded_fields))
283 |
284 | if including_or_excluding_field_more_than_once:
285 | repeated_fields = get_duplicates(included_and_excluded_fields)
286 | msg = (
287 | "QueryFormatError: You have either "
288 | "included/excluded a field more than once, " # e.g {id, id}
289 | "used the same alias more than once, " # e.g {x: name, x: age}
290 | "used a field name as an alias to another field or " # e.g {id, id: age} Here age's not a parent
291 | "renamed a field and included/excluded it again, " # e.g {ID: id, id}
292 | "The list of fields which led to this error is %s."
293 | ) % str(repeated_fields)
294 | raise ValidationError(msg, "invalid")
295 |
296 | if excluded_fields:
297 | # Here we are sure that parsed_query.excluded_fields
298 | # is not empty which means the user specified fields to exclude,
299 | # so we just check if provided fields exists then remove them from
300 | # a list of all fields
301 | for field in excluded_fields:
302 | self.is_field_found(field, all_fields, raise_exception=True)
303 | all_fields.pop(field)
304 |
305 | elif included_fields and not include_all_fields:
306 | # Here we are sure that parsed_query.excluded_fields
307 | # is empty which means the exclude operator(-) has not been used,
308 | # so parsed_query.included_fields contains only selected fields
309 | all_allowed_fields = set(allowed_flat_fields) | set(
310 | allowed_nested_fields.keys()
311 | )
312 |
313 | existing_fields = set(all_fields.keys())
314 |
315 | non_selected_fields = existing_fields - all_allowed_fields
316 |
317 | for field in non_selected_fields:
318 | # Remove it because we're sure it has not been selected
319 | all_fields.pop(field)
320 |
321 | elif include_all_fields:
322 | # Here we are sure both parsed_query.excluded_fields and
323 | # parsed_query.included_fields are empty, but * has been
324 | # used to select all fields, so we return all fields without
325 | # removing any
326 | pass
327 |
328 | else:
329 | # Otherwise the user specified empty query i.e query={}
330 | # So we return nothing
331 | all_fields = {}
332 |
333 | return all_fields, allowed_nested_fields
334 |
335 | @cached_property
336 | def dynamic_fields(self):
337 | parsed_restql_query = None
338 |
339 | is_root_serializer = self.parent is None or (
340 | isinstance(self.parent, ListSerializer) and self.parent.parent is None
341 | )
342 |
343 | if is_root_serializer:
344 | try:
345 | parsed_restql_query = self.get_parsed_restql_query()
346 | except SyntaxError as e:
347 | msg = "QuerySyntaxError: " + e.msg + " on " + e.text
348 | raise ValidationError(msg, code="invalid") from None
349 | except QueryFormatError as e:
350 | msg = "QueryFormatError: " + str(e)
351 | raise ValidationError(msg, code="invalid") from None
352 |
353 | elif isinstance(self.parent, ListSerializer):
354 | field_name = self.parent.field_name
355 | parent = self.parent.parent
356 | if hasattr(parent, "restql_nested_parsed_queries"):
357 | parent_nested_fields = parent.restql_nested_parsed_queries
358 | parsed_restql_query = parent_nested_fields.get(field_name, None)
359 | elif isinstance(self.parent, Serializer):
360 | field_name = self.field_name
361 | parent = self.parent
362 | if hasattr(parent, "restql_nested_parsed_queries"):
363 | parent_nested_fields = parent.restql_nested_parsed_queries
364 | parsed_restql_query = parent_nested_fields.get(field_name, None)
365 |
366 | if parsed_restql_query is None:
367 | # There's no query so we return all fields
368 | return self.allowed_fields
369 |
370 | # Get fields selected by `query` parameter
371 | selected_fields, nested_parsed_queries = self.select_fields(
372 | parsed_query=parsed_restql_query, all_fields=self.allowed_fields
373 | )
374 |
375 | # Keep track of parsed queries of nested fields
376 | # for future reference from child/nested serializers
377 | self.restql_nested_parsed_queries = nested_parsed_queries
378 | return selected_fields
379 |
380 | def get_parsed_restql_query_from_query_kwarg(self):
381 | parser = QueryParser()
382 | return parser.parse(self.dynamic_fields_mixin_kwargs["query"])
383 |
384 | def get_parsed_restql_query(self):
385 | request = self.context.get("request")
386 |
387 | if self.dynamic_fields_mixin_kwargs["query"] is not None:
388 | # Get from query kwarg
389 | return self.get_parsed_restql_query_from_query_kwarg()
390 | elif self.dynamic_fields_mixin_kwargs["parsed_query"] is not None:
391 | # Get from parsed_query kwarg
392 | return self.dynamic_fields_mixin_kwargs["parsed_query"]
393 | elif request is not None and self.has_restql_query_param(request):
394 | # Get from request query parameter
395 | return self.get_parsed_restql_query_from_req(request)
396 | return None # There is no query so we return None as a parsed query
397 |
398 | @property
399 | def fields(self):
400 | should_use_dynamic_fields = (
401 | self.is_ready_to_use_dynamic_fields
402 | and not self.dynamic_fields_mixin_kwargs["disable_dynamic_fields"]
403 | )
404 |
405 | if should_use_dynamic_fields:
406 | # Return restql fields
407 | return self.dynamic_fields
408 | return self.allowed_fields
409 |
410 |
411 | class EagerLoadingMixin(RequestQueryParserMixin):
412 | @property
413 | def parsed_restql_query(self):
414 | """
415 | Gets parsed query for use in eager loading.
416 | Defaults to the serializer parsed query.
417 | """
418 | if self.has_restql_query_param(self.request):
419 | try:
420 | return self.get_parsed_restql_query_from_req(self.request)
421 | except (SyntaxError, QueryFormatError):
422 | # Let `DynamicFieldsMixin` handle this for a user
423 | # to get a helpful error message
424 | pass
425 |
426 | # Else include all fields
427 | query = Query(
428 | field_name=None,
429 | included_fields=["*"],
430 | excluded_fields=[],
431 | aliases={},
432 | arguments={},
433 | )
434 | return query
435 |
436 | @property
437 | def should_auto_apply_eager_loading(self):
438 | if hasattr(self, "auto_apply_eager_loading"):
439 | return self.auto_apply_eager_loading
440 | return restql_settings.AUTO_APPLY_EAGER_LOADING
441 |
442 | def get_select_related_mapping(self):
443 | if hasattr(self, "select_related"):
444 | return self.select_related
445 | # Else select nothing
446 | return {}
447 |
448 | def get_prefetch_related_mapping(self):
449 | if hasattr(self, "prefetch_related"):
450 | return self.prefetch_related
451 | # Else prefetch nothing
452 | return {}
453 |
454 | @classmethod
455 | def get_dict_parsed_restql_query(cls, parsed_restql_query):
456 | """
457 | Returns the parsed query as a dict.
458 | """
459 | parsed_query = {}
460 | included_fields = parsed_restql_query.included_fields
461 | excluded_fields = parsed_restql_query.excluded_fields
462 |
463 | for field in included_fields:
464 | if isinstance(field, Query):
465 | nested_keys = cls.get_dict_parsed_restql_query(field)
466 | parsed_query[field.field_name] = nested_keys
467 | else:
468 | parsed_query[field] = True
469 | for field in excluded_fields:
470 | if isinstance(field, Query):
471 | nested_keys = cls.get_dict_parsed_restql_query(field)
472 | parsed_query[field.field_name] = nested_keys
473 | else:
474 | parsed_query[field] = False
475 | return parsed_query
476 |
477 | @staticmethod
478 | def get_related_fields(related_fields_mapping, dict_parsed_restql_query):
479 | """
480 | Returns only whitelisted related fields from a query to be used on
481 | `select_related` and `prefetch_related`
482 | """
483 | related_fields = []
484 | for key, related_field in related_fields_mapping.items():
485 | fields = key.split(".")
486 | if isinstance(related_field, (str, Prefetch)):
487 | related_field = [related_field]
488 |
489 | query_node = dict_parsed_restql_query
490 | for field in fields:
491 | if isinstance(query_node, dict):
492 | if field in query_node:
493 | # Get a more specific query node
494 | query_node = query_node[field]
495 | elif "*" in query_node:
496 | # All fields are included
497 | continue
498 | else:
499 | # The field is not included in a query so
500 | # don't include this field in `related_fields`
501 | break
502 | else:
503 | # If the loop completed without breaking
504 | if isinstance(query_node, dict) or query_node:
505 | related_fields.extend(related_field)
506 | return related_fields
507 |
508 | def apply_eager_loading(self, queryset):
509 | """
510 | Applies appropriate select_related and prefetch_related calls on a
511 | queryset
512 | """
513 | query = self.get_dict_parsed_restql_query(self.parsed_restql_query)
514 | select_mapping = self.get_select_related_mapping()
515 | prefetch_mapping = self.get_prefetch_related_mapping()
516 |
517 | to_select = self.get_related_fields(select_mapping, query)
518 | to_prefetch = self.get_related_fields(prefetch_mapping, query)
519 |
520 | if to_select:
521 | queryset = queryset.select_related(*to_select)
522 | if to_prefetch:
523 | queryset = queryset.prefetch_related(*to_prefetch)
524 | return queryset
525 |
526 | def get_eager_queryset(self, queryset):
527 | return self.apply_eager_loading(queryset)
528 |
529 | def get_queryset(self):
530 | """
531 | Override for DRF's get_queryset on the view.
532 | If get_queryset is not present, we don't try to run this.
533 | Instead, this can still be used by manually calling
534 | self.get_eager_queryset and passing in the queryset desired.
535 | """
536 | if hasattr(super(), "get_queryset"):
537 | queryset = super().get_queryset()
538 | if self.should_auto_apply_eager_loading:
539 | queryset = self.get_eager_queryset(queryset)
540 | return queryset
541 |
542 |
543 | class BaseNestedMixin(object):
544 | def get_fields(self):
545 | # Replace all temporary fields with the actual fields
546 | fields = super().get_fields()
547 | for field_name, field in fields.items():
548 | if isinstance(field, TemporaryNestedField):
549 | fields.update(
550 | {field_name: field.get_actual_nested_field(self.__class__)}
551 | )
552 | return fields
553 |
554 | @cached_property
555 | def restql_writable_nested_fields(self):
556 | # Make field_source -> field_value map for restql nested fields
557 | writable_nested_fields = {}
558 | for _, field in self.fields.items():
559 | # Get the actual source of the field
560 | if isinstance(field, BaseRESTQLNestedField):
561 | writable_nested_fields.update({field.source: field})
562 | return writable_nested_fields
563 |
564 |
565 | class NestedCreateMixin(BaseNestedMixin):
566 | """Create Mixin"""
567 |
568 | def create_writable_foreignkey_related(self, data):
569 | # data format
570 | # {field: {sub_field: value}}
571 | objs = {}
572 | nested_fields = self.restql_writable_nested_fields
573 | for field, value in data.items():
574 | # Get nested field serializer
575 | nested_field_serializer = nested_fields[field]
576 | serializer_class = nested_field_serializer.serializer_class
577 | kwargs = nested_field_serializer.validation_kwargs
578 | serializer = serializer_class(
579 | **kwargs,
580 | data=value,
581 | # Reject partial update by default(if partial kwarg is not passed)
582 | # since we need all required fields when creating object
583 | partial=nested_field_serializer.is_partial(False),
584 | context={**self.context, "parent_operation": CREATE},
585 | )
586 | serializer.is_valid(raise_exception=True)
587 | if value is None:
588 | objs.update({field: None})
589 | else:
590 | obj = serializer.save()
591 | objs.update({field: obj})
592 | return objs
593 |
594 | def bulk_create_objs(self, field, data):
595 | nested_fields = self.restql_writable_nested_fields
596 |
597 | # Get nested field serializer
598 | nested_field_serializer = nested_fields[field].child
599 | serializer_class = nested_field_serializer.serializer_class
600 | kwargs = nested_field_serializer.validation_kwargs
601 | pks = []
602 | for values in data:
603 | serializer = serializer_class(
604 | **kwargs,
605 | data=values,
606 | # Reject partial update by default(if partial kwarg is not passed)
607 | # since we need all required fields when creating object
608 | partial=nested_field_serializer.is_partial(False),
609 | context={**self.context, "parent_operation": CREATE},
610 | )
611 | serializer.is_valid(raise_exception=True)
612 | obj = serializer.save()
613 | pks.append(obj.pk)
614 | return pks
615 |
616 | def create_many_to_one_related(self, instance, data):
617 | # data format
618 | # {field: {
619 | # ADD: [pks],
620 | # CREATE: [{sub_field: value}]
621 | # }...}
622 | field_pks = {}
623 | for field, values in data.items():
624 | model = self.Meta.model
625 | foreignkey = getattr(model, field).field.name
626 | nested_fields = self.restql_writable_nested_fields
627 | for operation in values:
628 | if operation == ADD:
629 | pks = values[operation]
630 | model = nested_fields[field].child.Meta.model
631 | qs = model.objects.filter(pk__in=pks)
632 | qs.update(**{foreignkey: instance.pk})
633 | field_pks.update({field: pks})
634 | elif operation == CREATE:
635 | for v in values[operation]:
636 | v.update({foreignkey: instance.pk})
637 | pks = self.bulk_create_objs(field, values[operation])
638 | field_pks.update({field: pks})
639 | return field_pks
640 |
641 | def create_many_to_one_generic_related(self, instance, data):
642 | field_pks = {}
643 | nested_fields = self.restql_writable_nested_fields
644 |
645 | content_type = (
646 | ContentType.objects.get_for_model(instance) if ContentType else None
647 | )
648 | for field, values in data.items():
649 | relation = getattr(self.Meta.model, field).field
650 |
651 | nested_field_serializer = nested_fields[field].child
652 | serializer_class = nested_field_serializer.serializer_class
653 | kwargs = nested_field_serializer.validation_kwargs
654 | model = nested_field_serializer.Meta.model
655 |
656 | for operation in values:
657 | if operation == ADD:
658 | pks = values[operation]
659 | qs = model.objects.filter(pk__in=pks)
660 | qs.update(
661 | **{
662 | relation.object_id_field_name: instance.pk,
663 | relation.content_type_field_name: content_type,
664 | }
665 | )
666 | elif operation == CREATE:
667 | serializer = serializer_class(
668 | data=values[operation], **kwargs, many=True
669 | )
670 | serializer.is_valid(raise_exception=True)
671 | items = serializer.validated_data
672 |
673 | objs = [
674 | model(
675 | **item,
676 | **{
677 | relation.content_type_field_name: content_type,
678 | relation.object_id_field_name: instance.pk,
679 | },
680 | )
681 | for item in items
682 | ]
683 | objs = model.objects.bulk_create(objs)
684 | field_pks[field] = [obj.pk for obj in objs]
685 |
686 | return field_pks
687 |
688 | def create_many_to_many_related(self, instance, data):
689 | # data format
690 | # {field: {
691 | # ADD: [pks],
692 | # CREATE: [{sub_field: value}]
693 | # }...}
694 | field_pks = {}
695 | for field, values in data.items():
696 | obj = getattr(instance, field)
697 | for operation in values:
698 | if operation == ADD:
699 | pks = values[operation]
700 | obj.add(*pks)
701 | field_pks.update({field: pks})
702 | elif operation == CREATE:
703 | pks = self.bulk_create_objs(field, values[operation])
704 | obj.add(*pks)
705 | field_pks.update({field: pks})
706 | return field_pks
707 |
708 | def create(self, validated_data):
709 | # Make a copy of validated_data so that we don't
710 | # alter it in case user need to access it later
711 | validated_data_copy = {**validated_data}
712 |
713 | fields = {
714 | "foreignkey_related": {"replaceable": {}, "writable": {}},
715 | "many_to": {
716 | "many_related": {},
717 | "one_related": {},
718 | "one_generic_related": {},
719 | },
720 | }
721 |
722 | restql_nested_fields = self.restql_writable_nested_fields
723 | for field in restql_nested_fields:
724 | if field not in validated_data_copy:
725 | # Nested field value is not provided
726 | continue
727 |
728 | field_serializer = restql_nested_fields[field]
729 |
730 | if isinstance(field_serializer, Serializer):
731 | if field_serializer.is_replaceable:
732 | value = validated_data_copy.pop(field)
733 | fields["foreignkey_related"]["replaceable"].update({field: value})
734 | else:
735 | value = validated_data_copy.pop(field)
736 | fields["foreignkey_related"]["writable"].update({field: value})
737 | elif isinstance(field_serializer, ListSerializer):
738 | model = self.Meta.model
739 | rel = getattr(model, field).rel
740 |
741 | if isinstance(rel, ManyToOneRel):
742 | value = validated_data_copy.pop(field)
743 | fields["many_to"]["one_related"].update({field: value})
744 | elif isinstance(rel, ManyToManyRel):
745 | value = validated_data_copy.pop(field)
746 | fields["many_to"]["many_related"].update({field: value})
747 | elif GenericRel and isinstance(rel, GenericRel):
748 | value = validated_data_copy.pop(field)
749 | fields["many_to"]["one_generic_related"].update({field: value})
750 |
751 | foreignkey_related = {
752 | **fields["foreignkey_related"]["replaceable"],
753 | **self.create_writable_foreignkey_related(
754 | fields["foreignkey_related"]["writable"]
755 | ),
756 | }
757 |
758 | instance = super().create({**validated_data_copy, **foreignkey_related})
759 |
760 | self.create_many_to_many_related(instance, fields["many_to"]["many_related"])
761 |
762 | self.create_many_to_one_related(instance, fields["many_to"]["one_related"])
763 |
764 | if fields["many_to"]["one_generic_related"]:
765 | # Call create_many_to_one_generic_related only if we have generic relationship
766 | self.create_many_to_one_generic_related(
767 | instance, fields["many_to"]["one_generic_related"]
768 | )
769 |
770 | return instance
771 |
772 |
773 | class NestedUpdateMixin(BaseNestedMixin):
774 | """Update Mixin"""
775 |
776 | @staticmethod
777 | def constrain_error_prefix(field):
778 | return "Error on `%s` field: " % (field,)
779 |
780 | @staticmethod
781 | def update_replaceable_foreignkey_related(instance, data):
782 | # data format {field: obj}
783 | for field, nested_obj in data.items():
784 | setattr(instance, field, nested_obj)
785 | if data:
786 | instance.save()
787 |
788 | def update_writable_foreignkey_related(self, instance, data):
789 | # data format {field: {sub_field: value}}
790 | nested_fields = self.restql_writable_nested_fields
791 |
792 | needs_save = False
793 | for field, values in data.items():
794 | # Get nested field serializer
795 | nested_field_serializer = nested_fields[field]
796 | serializer_class = nested_field_serializer.serializer_class
797 | kwargs = nested_field_serializer.validation_kwargs
798 | nested_obj = getattr(instance, field)
799 | serializer = serializer_class(
800 | nested_obj,
801 | **kwargs,
802 | data=values,
803 | # Allow partial update by default(if partial kwarg is not passed)
804 | # since this is nested update
805 | partial=nested_field_serializer.is_partial(True),
806 | context={**self.context, "parent_operation": UPDATE},
807 | )
808 | serializer.is_valid(raise_exception=True)
809 | if values is None:
810 | setattr(instance, field, None)
811 | needs_save = True
812 | else:
813 | obj = serializer.save()
814 | if nested_obj is None:
815 | # Patch back newly created object to instance
816 | setattr(instance, field, obj)
817 | needs_save = True
818 | if needs_save:
819 | instance.save()
820 |
821 | def bulk_create_many_to_many_related(self, field, nested_obj, data):
822 | # Get nested field serializer
823 | nested_field_serializer = self.restql_writable_nested_fields[field].child
824 | serializer_class = nested_field_serializer.serializer_class
825 | kwargs = nested_field_serializer.validation_kwargs
826 | pks = []
827 | for values in data:
828 | serializer = serializer_class(
829 | **kwargs,
830 | data=values,
831 | # Reject partial update by default(if partial kwarg is not passed)
832 | # since we need all required fields when creating object
833 | partial=nested_field_serializer.is_partial(False),
834 | context={**self.context, "parent_operation": CREATE},
835 | )
836 | serializer.is_valid(raise_exception=True)
837 | obj = serializer.save()
838 | pks.append(obj.pk)
839 | nested_obj.add(*pks)
840 | return pks
841 |
842 | def bulk_create_many_to_one_related(self, field, nested_obj, data):
843 | # Get nested field serializer
844 | nested_field_serializer = self.restql_writable_nested_fields[field].child
845 | serializer_class = nested_field_serializer.serializer_class
846 | kwargs = nested_field_serializer.validation_kwargs
847 | pks = []
848 | for values in data:
849 | serializer = serializer_class(
850 | **kwargs,
851 | data=values,
852 | # Reject partial update by default(if partial kwarg is not passed)
853 | # since we need all required fields when creating object
854 | partial=nested_field_serializer.is_partial(False),
855 | context={**self.context, "parent_operation": CREATE},
856 | )
857 | serializer.is_valid(raise_exception=True)
858 | obj = serializer.save()
859 | pks.append(obj.pk)
860 | return pks
861 |
862 | def bulk_update_many_to_many_related(self, field, nested_obj, data):
863 | # {pk: {sub_field: values}}
864 |
865 | # Get nested field serializer
866 | nested_field_serializer = self.restql_writable_nested_fields[field].child
867 | serializer_class = nested_field_serializer.serializer_class
868 | kwargs = nested_field_serializer.validation_kwargs
869 | for pk, values in data.items():
870 | try:
871 | obj = nested_obj.get(pk=pk)
872 | except ObjectDoesNotExist:
873 | # This pk does't belong to nested field
874 | continue
875 | serializer = serializer_class(
876 | obj,
877 | **kwargs,
878 | data=values,
879 | # Allow partial update by default(if partial kwarg is not passed)
880 | # since this is nested update
881 | partial=nested_field_serializer.is_partial(True),
882 | context={**self.context, "parent_operation": UPDATE},
883 | )
884 | serializer.is_valid(raise_exception=True)
885 | obj = serializer.save()
886 |
887 | def bulk_update_many_to_one_related(
888 | self, field, instance, data, update_foreign_key=True
889 | ):
890 | # {pk: {sub_field: values}}
891 |
892 | # Get nested field serializer
893 | nested_field_serializer = self.restql_writable_nested_fields[field].child
894 | serializer_class = nested_field_serializer.serializer_class
895 | kwargs = nested_field_serializer.validation_kwargs
896 | model = self.Meta.model
897 | foreignkey = getattr(model, field).field.name
898 | nested_obj = getattr(instance, field)
899 | for pk, values in data.items():
900 | try:
901 | obj = nested_obj.get(pk=pk)
902 | except ObjectDoesNotExist:
903 | # This pk does't belong to nested field
904 | continue
905 | if update_foreign_key:
906 | values.update({foreignkey: instance.pk})
907 | serializer = serializer_class(
908 | obj,
909 | **kwargs,
910 | data=values,
911 | # Allow partial update by default(if partial kwarg is not passed)
912 | # since this is nested update
913 | partial=nested_field_serializer.is_partial(True),
914 | context={**self.context, "parent_operation": UPDATE},
915 | )
916 | serializer.is_valid(raise_exception=True)
917 | obj = serializer.save()
918 |
919 | def update_many_to_one_related(self, instance, data):
920 | # data format
921 | # {field: {
922 | # ADD: [{sub_field: value}],
923 | # CREATE: [{sub_field: value}],
924 | # REMOVE: [pk],
925 | # UPDATE: {pk: {sub_field: value}}
926 | # }...}
927 | for field, values in data.items():
928 | nested_obj = getattr(instance, field)
929 | model = self.Meta.model
930 | foreignkey = getattr(model, field).field.name
931 | nested_fields = self.restql_writable_nested_fields
932 | for operation in values:
933 | if operation == ADD:
934 | pks = values[operation]
935 | model = nested_fields[field].child.Meta.model
936 | qs = model.objects.filter(pk__in=pks)
937 | qs.update(**{foreignkey: instance.pk})
938 | elif operation == CREATE:
939 | for v in values[operation]:
940 | v.update({foreignkey: instance.pk})
941 | self.bulk_create_many_to_one_related(
942 | field, nested_obj, values[operation]
943 | )
944 | elif operation == REMOVE:
945 | qs = nested_obj.all()
946 | if values[operation] == ALL_RELATED_OBJS:
947 | qs.delete()
948 | else:
949 | qs.filter(pk__in=values[operation]).delete()
950 | elif operation == UPDATE:
951 | self.bulk_update_many_to_one_related(
952 | field, instance, values[operation]
953 | )
954 | else:
955 | message = "`%s` is an invalid operation" % (operation,)
956 | raise ValidationError(message, code="invalid_operation")
957 | return instance
958 |
959 | def update_many_to_one_generic_related(self, instance, data):
960 | # it's same logic for add & create operations
961 | # which are already handled by NestedCreateMixin
962 | NestedCreateMixin.create_many_to_one_generic_related(self, instance, data)
963 |
964 | for field, values in data.items():
965 | nested_qs = getattr(instance, field)
966 | for operation in values:
967 | if operation not in [ADD, CREATE, UPDATE, REMOVE]:
968 | message = f"`{operation}` is an invalid operation"
969 | raise ValidationError(message, code="invalid_operation")
970 |
971 | if operation == REMOVE:
972 | qs = nested_qs.all()
973 | if values[operation] == ALL_RELATED_OBJS:
974 | qs.delete()
975 | else:
976 | qs.filter(pk__in=values[operation]).delete()
977 | elif operation == UPDATE:
978 | self.bulk_update_many_to_one_related(
979 | field, instance, values[operation], update_foreign_key=False
980 | )
981 | return instance
982 |
983 | def update_many_to_many_related(self, instance, data):
984 | # data format
985 | # {field: {
986 | # ADD: [{sub_field: value}],
987 | # CREATE: [{sub_field: value}],
988 | # REMOVE: [pk],
989 | # UPDATE: {pk: {sub_field: value}}
990 | # }...}
991 | for field, values in data.items():
992 | nested_obj = getattr(instance, field)
993 | for operation in values:
994 | if operation == ADD:
995 | pks = values[operation]
996 | try:
997 | nested_obj.add(*pks)
998 | except Exception as e:
999 | msg = self.constrain_error_prefix(field) + str(e)
1000 | code = "constrain_error"
1001 | raise ValidationError(msg, code=code) from None
1002 | elif operation == CREATE:
1003 | self.bulk_create_many_to_many_related(
1004 | field, nested_obj, values[operation]
1005 | )
1006 | elif operation == REMOVE:
1007 | pks = values[operation]
1008 | if pks == ALL_RELATED_OBJS:
1009 | pks = nested_obj.all()
1010 | try:
1011 | nested_obj.remove(*pks)
1012 | except Exception as e:
1013 | msg = self.constrain_error_prefix(field) + str(e)
1014 | code = "constrain_error"
1015 | raise ValidationError(msg, code=code) from None
1016 | elif operation == UPDATE:
1017 | self.bulk_update_many_to_many_related(
1018 | field, nested_obj, values[operation]
1019 | )
1020 | else:
1021 | message = "`%s` is an invalid operation" % (operation,)
1022 | raise ValidationError(message, code="invalid_operation")
1023 | return instance
1024 |
1025 | def update(self, instance, validated_data):
1026 | # Make a copty of validated_data so that we don't
1027 | # alter it in case user need to access it later
1028 | validated_data_copy = {**validated_data}
1029 |
1030 | fields = {
1031 | "foreignkey_related": {"replaceable": {}, "writable": {}},
1032 | "many_to": {
1033 | "many_related": {},
1034 | "one_related": {},
1035 | "one_generic_related": {},
1036 | },
1037 | }
1038 |
1039 | restql_nested_fields = self.restql_writable_nested_fields
1040 | for field in restql_nested_fields:
1041 | if field not in validated_data_copy:
1042 | # Nested field value is not provided
1043 | continue
1044 |
1045 | field_serializer = restql_nested_fields[field]
1046 |
1047 | if isinstance(field_serializer, Serializer):
1048 | if field_serializer.is_replaceable:
1049 | value = validated_data_copy.pop(field)
1050 | fields["foreignkey_related"]["replaceable"].update({field: value})
1051 | else:
1052 | value = validated_data_copy.pop(field)
1053 | fields["foreignkey_related"]["writable"].update({field: value})
1054 | elif isinstance(field_serializer, ListSerializer):
1055 | model = self.Meta.model
1056 | rel = getattr(model, field).rel
1057 |
1058 | if isinstance(rel, ManyToOneRel):
1059 | value = validated_data_copy.pop(field)
1060 | fields["many_to"]["one_related"].update({field: value})
1061 | elif isinstance(rel, ManyToManyRel):
1062 | value = validated_data_copy.pop(field)
1063 | fields["many_to"]["many_related"].update({field: value})
1064 | elif GenericRel and isinstance(rel, GenericRel):
1065 | value = validated_data_copy.pop(field)
1066 | fields["many_to"]["one_generic_related"].update({field: value})
1067 |
1068 | instance = super().update(instance, validated_data_copy)
1069 |
1070 | self.update_replaceable_foreignkey_related(
1071 | instance, fields["foreignkey_related"]["replaceable"]
1072 | )
1073 |
1074 | self.update_writable_foreignkey_related(
1075 | instance, fields["foreignkey_related"]["writable"]
1076 | )
1077 |
1078 | self.update_many_to_many_related(instance, fields["many_to"]["many_related"])
1079 |
1080 | self.update_many_to_one_related(instance, fields["many_to"]["one_related"])
1081 |
1082 | if fields["many_to"]["one_generic_related"]:
1083 | # Call update_many_to_one_generic_related only if we have generic relationship
1084 | self.update_many_to_one_generic_related(
1085 | instance, fields["many_to"]["one_generic_related"]
1086 | )
1087 | return instance
1088 |
--------------------------------------------------------------------------------
/django_restql/operations.py:
--------------------------------------------------------------------------------
1 | ADD = "add"
2 | CREATE = "create"
3 | REMOVE = "remove"
4 | UPDATE = "update"
5 |
--------------------------------------------------------------------------------
/django_restql/parser.py:
--------------------------------------------------------------------------------
1 | import re
2 | from collections import namedtuple
3 |
4 | from pypeg2 import List, contiguous, csl, name, optional, parse
5 |
6 | from .exceptions import QueryFormatError
7 |
8 |
9 | class Alias(List):
10 | grammar = name(), ':'
11 |
12 |
13 | class IncludedField(List):
14 | grammar = optional(Alias), name()
15 |
16 | @property
17 | def alias(self):
18 | if len(self) > 0:
19 | return self[0].name
20 | return None
21 |
22 |
23 | class ExcludedField(List):
24 | grammar = contiguous('-', name())
25 |
26 |
27 | class AllFields(str):
28 | grammar = '*'
29 |
30 |
31 | class ArgumentWithoutQuotes(List):
32 | grammar = name(), ':', re.compile(r'true|false|null|[-+]?[0-9]*\.?[0-9]+')
33 |
34 | def number(self, val):
35 | try:
36 | return int(val)
37 | except ValueError:
38 | return float(val)
39 |
40 | @property
41 | def value(self):
42 | raw_val = self[0]
43 | FIXED_DATA_TYPES = {
44 | 'true': True,
45 | 'false': False,
46 | 'null': None
47 | }
48 | if raw_val in FIXED_DATA_TYPES:
49 | return FIXED_DATA_TYPES[raw_val]
50 | return self.number(raw_val)
51 |
52 |
53 | class ArgumentWithQuotes(List):
54 | grammar = name(), ':', re.compile(r'"([^"\\]|\\.|\\\n)*"|\'([^\'\\]|\\.|\\\n)*\'')
55 |
56 | @property
57 | def value(self):
58 | # Slicing is for removing quotes
59 | # at the begining and end of a string
60 | return self[0][1:-1]
61 |
62 |
63 | class Arguments(List):
64 | grammar = optional(csl(
65 | [
66 | ArgumentWithoutQuotes,
67 | ArgumentWithQuotes,
68 | ],
69 | separator=[',', '']
70 | ))
71 |
72 |
73 | class ArgumentsBlock(List):
74 | grammar = optional('(', Arguments, optional(','), ')')
75 |
76 | @property
77 | def arguments(self):
78 | if self[0] is None:
79 | return [] # No arguments
80 | return self[0]
81 |
82 |
83 | class ParentField(List):
84 | """
85 | According to ParentField grammar:
86 | self[0] returns IncludedField instance,
87 | self[1] returns Block instance
88 | """
89 | @property
90 | def name(self):
91 | return self[0].name
92 |
93 | @property
94 | def alias(self):
95 | return self[0].alias
96 |
97 | @property
98 | def block(self):
99 | return self[1]
100 |
101 |
102 | class BlockBody(List):
103 | grammar = optional(csl(
104 | [ParentField, IncludedField, ExcludedField, AllFields],
105 | separator=[',', '']
106 | ))
107 |
108 |
109 | class Block(List):
110 | grammar = ArgumentsBlock, '{', BlockBody, optional(','), '}'
111 |
112 | @property
113 | def arguments(self):
114 | return self[0].arguments
115 |
116 | @property
117 | def body(self):
118 | return self[1]
119 |
120 |
121 | # ParentField grammar,
122 | # We don't include `ExcludedField` here because
123 | # exclude operator(-) on a parent field should
124 | # raise syntax error, e.g {name, -location{city}}
125 | # `IncludedField` is a parent field and `Block`
126 | # contains its sub fields
127 | ParentField.grammar = IncludedField, Block
128 |
129 |
130 | Query = namedtuple(
131 | "Query",
132 | ("field_name", "included_fields", "excluded_fields", "aliases", "arguments")
133 | )
134 |
135 |
136 | class QueryParser(object):
137 | def parse(self, query):
138 | parse_tree = parse(query, Block)
139 | return self._transform_block(parse_tree, parent_field=None)
140 |
141 | def _transform_block(self, block, parent_field=None):
142 | query = Query(
143 | field_name=parent_field,
144 | included_fields=[],
145 | excluded_fields=[],
146 | aliases={},
147 | arguments={}
148 | )
149 |
150 | for argument in block.arguments:
151 | argument = {str(argument.name): argument.value}
152 | query.arguments.update(argument)
153 |
154 | for field in block.body:
155 | # A field may be a parent or included field or excluded field
156 | if isinstance(field, (ParentField, IncludedField)):
157 | # Find all aliases
158 | if field.alias:
159 | query.aliases.update({str(field.name): str(field.alias)})
160 |
161 | field = self._transform_field(field)
162 |
163 | if isinstance(field, Query):
164 | # A field is a parent
165 | query.included_fields.append(field)
166 | elif isinstance(field, IncludedField):
167 | query.included_fields.append(str(field.name))
168 | elif isinstance(field, ExcludedField):
169 | query.excluded_fields.append(str(field.name))
170 | elif isinstance(field, AllFields):
171 | # include all fields
172 | query.included_fields.append("*")
173 |
174 | if query.excluded_fields and "*" not in query.included_fields:
175 | query.included_fields.append("*")
176 |
177 | field_names = set(query.aliases.values())
178 | field_aliases = set(query.aliases.keys())
179 | faulty_fields = field_names.intersection(field_aliases)
180 | if faulty_fields:
181 | # We check this here because if we let it pass during
182 | # parsing it's going to raise inappropriate error message
183 | # when checking fields availability(for the case of renamed parents)
184 | msg = (
185 | "You have either "
186 | "used an existing field name as an alias to another field or " # e.g {id, id: course{}}
187 | "you have defined an alias with the same name as a field name." # e.g {id: id}
188 | "The list of fields which led to this error is %s."
189 | ) % str(list(faulty_fields))
190 | raise QueryFormatError(msg)
191 | return query
192 |
193 | def _transform_field(self, field):
194 | # A field may be a parent or included field or excluded field
195 | if isinstance(field, ParentField):
196 | return self._transform_parent_field(field)
197 | return field
198 |
199 | def _transform_parent_field(self, parent_field):
200 | return self._transform_block(
201 | parent_field.block,
202 | parent_field=str(parent_field.name)
203 | )
204 |
--------------------------------------------------------------------------------
/django_restql/serializers.py:
--------------------------------------------------------------------------------
1 | from rest_framework.serializers import ModelSerializer
2 |
3 | from .mixins import NestedCreateMixin, NestedUpdateMixin
4 |
5 |
6 | class NestedModelSerializer(
7 | NestedCreateMixin,
8 | NestedUpdateMixin,
9 | ModelSerializer):
10 | pass
11 |
--------------------------------------------------------------------------------
/django_restql/settings.py:
--------------------------------------------------------------------------------
1 | """
2 | Settings for Django RESTQL are all namespaced in the RESTQL setting.
3 | For example your project's `settings.py` file might look like this:
4 | RESTQL = {
5 | 'QUERY_PARAM_NAME': 'query'
6 | }
7 | This module provides the `restql_settings` object, that is used to access
8 | Django RESTQL settings, checking for user settings first, then falling
9 | back to the defaults.
10 | """
11 | from django.conf import settings
12 | from django.test.signals import setting_changed
13 | from django.utils.module_loading import import_string
14 |
15 | DEFAULTS = {
16 | 'QUERY_PARAM_NAME': 'query',
17 | 'AUTO_APPLY_EAGER_LOADING': True,
18 | 'MAX_ALIAS_LEN': 50
19 | }
20 |
21 |
22 | # List of settings that may be in string import notation.
23 | IMPORT_STRINGS = [
24 |
25 | ]
26 |
27 |
28 | def perform_import(val, setting_name):
29 | """
30 | If the given setting is a string import notation,
31 | then perform the necessary import or imports.
32 | """
33 | if val is None:
34 | return None
35 | elif isinstance(val, str):
36 | return import_from_string(val, setting_name)
37 | elif isinstance(val, (list, tuple)):
38 | return [import_from_string(item, setting_name) for item in val]
39 | return val
40 |
41 |
42 | def import_from_string(val, setting_name):
43 | """
44 | Attempt to import a class from a string representation.
45 | """
46 | try:
47 | return import_string(val)
48 | except ImportError as e:
49 | msg = (
50 | "Could not import '%s' for RESTQL setting '%s'. %s: %s."
51 | ) % (val, setting_name, e.__class__.__name__, e)
52 | raise ImportError(msg)
53 |
54 |
55 | class RESTQLSettings:
56 | """
57 | A settings object, that allows RESTQL settings to be accessed as properties.
58 | For example:
59 | from django_restql.settings import restql_settings
60 | print(restql_settings.QUERY_PARAM_NAME)
61 | Any setting with string import paths will be automatically resolved
62 | and return the class, rather than the string literal.
63 | """
64 |
65 | def __init__(self, user_settings=None, defaults=None, import_strings=None):
66 | self.defaults = defaults or DEFAULTS
67 | self.import_strings = import_strings or IMPORT_STRINGS
68 | self._cached_attrs = set()
69 |
70 | @property
71 | def user_settings(self):
72 | if not hasattr(self, '_user_settings'):
73 | self._user_settings = getattr(settings, 'RESTQL', {})
74 | return self._user_settings
75 |
76 | def __getattr__(self, attr):
77 | if attr not in self.defaults:
78 | raise AttributeError("Invalid RESTQL setting: '%s'" % attr)
79 |
80 | try:
81 | # Check if present in user settings
82 | val = self.user_settings[attr]
83 | except KeyError:
84 | # Fall back to defaults
85 | val = self.defaults[attr]
86 |
87 | # Coerce import strings into classes
88 | if attr in self.import_strings:
89 | val = perform_import(val, attr)
90 |
91 | # Cache the result
92 | self._cached_attrs.add(attr)
93 | setattr(self, attr, val)
94 | return val
95 |
96 | def reload(self):
97 | for attr in self._cached_attrs:
98 | delattr(self, attr)
99 | self._cached_attrs.clear()
100 | if hasattr(self, '_user_settings'):
101 | delattr(self, '_user_settings')
102 |
103 |
104 | restql_settings = RESTQLSettings(None, DEFAULTS, IMPORT_STRINGS)
105 |
106 |
107 | def reload_restql_settings(*args, **kwargs):
108 | setting = kwargs['setting']
109 | if setting == 'RESTQL':
110 | restql_settings.reload()
111 |
112 |
113 | setting_changed.connect(reload_restql_settings)
114 |
--------------------------------------------------------------------------------
/docs/CNAME:
--------------------------------------------------------------------------------
1 | yezyilomo.github.io/django-restql
--------------------------------------------------------------------------------
/docs/extra.css:
--------------------------------------------------------------------------------
1 | .md-header{
2 | height: 53px;
3 | padding-top: 2px;
4 | }
5 |
6 | .md-search__form input{
7 | border-radius: 3px;
8 | }
9 |
10 | .md-source__icon svg{
11 | display: none;
12 | }
13 |
14 | .md-source__icon{
15 | background-image: url("img/github.svg");
16 | background-repeat: no-repeat;
17 | background-position: 50% center;
18 | background-size: 22px;
19 | }
--------------------------------------------------------------------------------
/docs/img/favicon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
8 |
--------------------------------------------------------------------------------
/docs/img/github.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/docs/img/icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
8 |
--------------------------------------------------------------------------------
/docs/index.md:
--------------------------------------------------------------------------------
1 | # Introduction
2 |
3 | **Django RESTQL** is a python library which allows you to turn your API made with **Django REST Framework(DRF)** into a GraphQL like API. With **Django RESTQL** you will be able to
4 |
5 | * Send a query to your API and get exactly what you need, nothing more and nothing less.
6 |
7 | * Control the data you get, not the server.
8 |
9 | * Get predictable results, since you control what you get from the server.
10 |
11 | * Get nested resources in a single request.
12 |
13 | * Avoid Over-fetching and Under-fetching of data.
14 |
15 | * Write(create & update) nested data of any level in a single request.
16 |
17 | Isn't it cool?.
18 |
19 |
20 | ## Requirements
21 | * Python >= 3.5
22 | * Django >= 1.11
23 | * Django REST Framework >= 3.5
24 |
25 |
26 | ## Installing
27 | ```py
28 | pip install django-restql
29 | ```
30 |
31 |
32 | ## Getting Started
33 | Using **Django RESTQL** to query data is very simple, you just have to inherit the `DynamicFieldsMixin` class when defining a serializer that's all.
34 | ```py
35 | from rest_framework import serializers
36 | from django.contrib.auth.models import User
37 | from django_restql.mixins import DynamicFieldsMixin
38 |
39 |
40 | class UserSerializer(DynamicFieldsMixin, serializer.ModelSerializer):
41 | class Meta:
42 | model = User
43 | fields = ['id', 'username', 'email']
44 | ```
45 |
46 | **Django RESTQL** handle all requests with a `query` parameter, this parameter is the one used to pass all fields to be included/excluded in a response. For example to select `id` and `username` fields from User model, send a request with a ` query` parameter as shown below.
47 |
48 | `GET /users/?query={id, username}`
49 | ```js
50 | [
51 | {
52 | "id": 1,
53 | "username": "yezyilomo"
54 | },
55 | ...
56 | ]
57 | ```
58 |
59 | **Django RESTQL** support querying both flat and nested resources, you can expand or query nested fields at any level as defined on a serializer. It also supports querying with all HTTP methods i.e (GET, POST, PUT & PATCH)
60 |
61 | You can do a lot with **Django RESTQL** apart from querying data, like
62 |
63 | - Rename fields
64 | - Restrict some fields on nested fields
65 | - Define self referencing nested fields
66 | - Optimize data fetching on nested fields
67 | - Data filtering and pagination by using query arguments
68 | - Data mutation(Create and update nested data of any level in a single request)
69 |
70 |
71 | ## Django RESTQL Play Ground
72 | [**Django RESTQL Play Ground**](https://django-restql-playground.yezyilomo.me) is a graphical, interactive, in-browser tool which you can use to test **Django RESTQL** features like data querying, mutations etc to get the idea of how the library works before installing it. It's more like a [**live demo**](https://django-restql-playground.yezyilomo.me) for **Django RESTQL**, it's available at [https://django-restql-playground.yezyilomo.me](https://django-restql-playground.yezyilomo.me)
73 |
--------------------------------------------------------------------------------
/docs/license.md:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 Yezy Ilomo
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/docs/mutating_data.md:
--------------------------------------------------------------------------------
1 | # Mutating Data
2 | **Django RESTQL** got your back on creating and updating nested data too, it has two components for mutating nested data, `NestedModelSerializer` and `NestedField`. A serializer `NestedModelSerializer` has `update` and `create` logics for nested fields on the other hand `NestedField` is used to validate data before calling `update` or `create` method.
3 |
4 |
5 | ## Using NestedField and NestedModelSerializer
6 | Just like in querying data, mutating nested data with **Django RESTQL** is very simple, you just have to inherit `NestedModelSerializer` on a serializer with nested fields and use `NestedField` to define those nested fields which you want to be able to mutate. Below is an example which shows how to use `NestedModelSerializer` and `NestedField`.
7 | ```py
8 | from rest_framework import serializers
9 | from django_restql.serializers import NestedModelSerializer
10 | from django_restql.fields import NestedField
11 |
12 | from app.models import Location, Amenity, Property
13 |
14 |
15 | class LocationSerializer(serializers.ModelSerializer):
16 | class Meta:
17 | model = Location
18 | fields = ["id", "city", "country"]
19 |
20 |
21 | class AmenitySerializer(serializers.ModelSerializer):
22 | class Meta:
23 | model = Amenity
24 | fields = ["id", "name"]
25 |
26 |
27 | # Inherit NestedModelSerializer to support create and update
28 | # on nested fields
29 | class PropertySerializer(NestedModelSerializer):
30 | # Define location as nested field
31 | location = NestedField(LocationSerializer)
32 |
33 | # Define amenities as nested field
34 | amenities = NestedField(AmenitySerializer, many=True)
35 | class Meta:
36 | model = Property
37 | fields = [
38 | 'id', 'price', 'location', 'amenities'
39 | ]
40 | ```
41 |
42 | With serializers defined as shown above, you will be able to send data mutation request like
43 |
44 | ```POST /api/property/```
45 |
46 | With a request body like
47 | ```js
48 | {
49 | "price": 60000,
50 | "location": {
51 | "city": "Newyork",
52 | "country": "USA"
53 | },
54 | "amenities": {
55 | "add": [3],
56 | "create": [
57 | {"name": "Watererr"},
58 | {"name": "Electricity"}
59 | ]
60 | }
61 | }
62 | ```
63 |
64 | And get a response as
65 | ```js
66 | {
67 | "id": 2,
68 | "price": 60000,
69 | "location": {
70 | "id": 3,
71 | "city": "Newyork",
72 | "country": "USA"
73 | },
74 | "amenities": [
75 | {"id": 1, "name": "Watererr"},
76 | {"id": 2, "name": "Electricity"},
77 | {"id": 3, "name": "Swimming Pool"}
78 | ]
79 | }
80 | ```
81 |
82 | Just to clarify what happed here:
83 |
84 | - location has been created and associated with the property created
85 | - `create` operation has created amenities with values specified in a list and associate them with the property
86 | - `add` operation has added amenity with id=3 to a list of amenities of the property.
87 |
88 | !!! note
89 | POST for many related fields supports two operations which are `create` and `add`.
90 |
91 |
92 | Below we have an example where we are trying to update the property we have created in the previous example.
93 |
94 | ```PUT/PATCH /api/property/2/```
95 |
96 | Request Body
97 | ```js
98 | {
99 | "price": 50000,
100 | "location": {
101 | "city": "Newyork",
102 | "country": "USA"
103 | },
104 | "amenities": {
105 | "add": [4],
106 | "create": [{"name": "Fance"}],
107 | "remove": [3],
108 | "update": {1: {"name": "Water"}}
109 | }
110 | }
111 | ```
112 |
113 | After sending the requst above we'll get a response which looks like
114 |
115 | ```js
116 | {
117 | "id": 2,
118 | "price": 50000,
119 | "location": {
120 | "id": 3,
121 | "city": "Newyork",
122 | "country": "USA"
123 | },
124 | "amenities": [
125 | {"id": 1, "name": "Water"},
126 | {"id": 2, "name": "Electricity"},
127 | {"id": 4, "name": "Bathtub"},
128 | {"id": 5, "name": "Fance"}
129 | ]
130 | }
131 | ```
132 |
133 | From the request body `add`, `create`, `remove` and `update` are operations
134 |
135 | What you see in the response above are details of our property, what really happened after sending the update request is
136 |
137 | - `add` operation added amenitiy with id=4 to a list of amenities of the property
138 | - `create` operation created amenities with values specified in a list
139 | - `remove` operation removed amenities with id=3 from a property
140 | - `update` operation updated amenity with id=1 according to values specified.
141 |
142 |
143 | !!! note
144 | PUT/PATCH for many related fields supports four operations which are `create`, `add`, `remove` and `update`.
145 |
146 |
147 | ## Self referencing nested field
148 | Currently DRF doesn't allow declaring self referencing nested fields but you might have a self referencing nested field in your project since Django allows creating them. Django RESTQL comes with a nice way to deal with this scenario.
149 |
150 | Let's assume we have a student model as shows below
151 |
152 | ```py
153 | # models.py
154 |
155 | class Student(models.Model):
156 | name = models.CharField(max_length=50)
157 | age = models.IntegerField()
158 | study_partners = models.ManyToManyField('self', related_name='study_partners')
159 | ```
160 |
161 | As you can see from the model above `study_partners` is a self referencing field. Below is the corresponding serializer for our model
162 |
163 | ```py
164 | # serializers.py
165 |
166 | class StudentSerializer(NestedModelSerializer):
167 | # Define study_partners as self referencing nested field
168 | study_partners = NestedField(
169 | 'self',
170 | many=True,
171 | required=False,
172 | exclude=['study_partners']
173 | )
174 |
175 | class Meta:
176 | model = Student
177 | fields = ['id', 'name', 'age', 'study_partners']
178 | ```
179 |
180 | You can see that we have passed `self` to `NestedField` just like in `Student` model, this means that `study_partners` field is a self referencing field.
181 |
182 | The other important thing here is `exclude=['study_partners']`, this excludes the field `study_partners` on a nested field to avoid recursion error if the self reference is cyclic.
183 |
184 |
185 | ## NestedField kwargs
186 | `NestedField` accepts extra kwargs in addition to those accepted by a serializer, these extra kwargs can be used to do more customizations on a nested field as explained below.
187 |
188 |
189 | ### accept_pk kwarg
190 | `accept_pk=True` is used if you want to be able to update nested field by using pk/id of existing data(basically associate existing nested resource with the parent resource). This applies to foreign key relations only. The default value for `accept_pk` is `False`.
191 |
192 | Below is an example showing how to use `accept_pk` kwarg.
193 |
194 | ```py
195 | from rest_framework import serializers
196 | from django_restql.fields import NestedField
197 | from django_restql.serializers import NestedModelSerializer
198 |
199 | from app.models import Location, Property
200 |
201 |
202 | class LocationSerializer(serializers.ModelSerializer):
203 | class Meta:
204 | model = Location
205 | fields = ["id", "city", "country"]
206 |
207 |
208 | class PropertySerializer(NestedModelSerializer):
209 | # pk based nested field
210 | location = NestedField(LocationSerializer, accept_pk=True)
211 | class Meta:
212 | model = Property
213 | fields = [
214 | 'id', 'price', 'location'
215 | ]
216 | ```
217 |
218 | Now sending mutation request as
219 |
220 |
221 | ```POST /api/property/```
222 |
223 | Request Body
224 | ```js
225 | {
226 | "price": 40000,
227 | "location": 2
228 | }
229 | ```
230 | !!! note
231 | Here location resource with id=2 exists already, so what's done here is create a new property resource and associate it with this location whose id is 2.
232 |
233 | Response
234 | ```js
235 | {
236 | "id": 1,
237 | "price": 40000,
238 | "location": {
239 | "id": 2,
240 | "city": "Tokyo",
241 | "country": "China"
242 | }
243 | }
244 | ```
245 |
246 | Using `accept_pk` doesn't limit you from sending data(instead of pk to nested resource), setting `accept_pk=True` means you can send both data and pks. For instance from the above example you could still do
247 |
248 | ```POST /api/property/```
249 |
250 | Request Body
251 | ```js
252 | {
253 | "price": 63000,
254 | "location": {
255 | "city": "Dodoma",
256 | "country": "Tanzania"
257 | }
258 | }
259 | ```
260 |
261 | Response
262 | ```js
263 | {
264 | "id": 2,
265 | "price": 63000,
266 | "location": {
267 | "id": 3,
268 | "city": "Dodoma",
269 | "country": "Tanzania"
270 | }
271 | }
272 | ```
273 |
274 |
275 | ### accept_pk_only kwarg
276 | `accept_pk_only=True` is used if you want to be able to update nested field by using pk/id only. This applies to foreign key relations only as well. The default value for `accept_pk_only` kwarg is `False`, if `accept_pk_only=True` is set you won't be able to send data to create a nested resource.
277 |
278 | Below is an example showing how to use `accept_pk_only` kwarg.
279 | ```py
280 | from rest_framework import serializers
281 | from django_restql.fields import NestedField
282 | from django_restql.serializers import NestedModelSerializer
283 |
284 | from app.models import Location, Property
285 |
286 |
287 | class LocationSerializer(serializers.ModelSerializer):
288 | class Meta:
289 | model = Location
290 | fields = ["id", "city", "country"]
291 |
292 |
293 | class PropertySerializer(NestedModelSerializer):
294 | # pk based nested field
295 | location = NestedField(LocationSerializer, accept_pk_only=True)
296 | class Meta:
297 | model = Property
298 | fields = [
299 | 'id', 'price', 'location'
300 | ]
301 | ```
302 |
303 | Sending mutation request
304 |
305 | ```POST /api/property/```
306 |
307 | Request Body
308 | ```js
309 | {
310 | "price": 40000,
311 | "location": 2 // You can't send data in here, you can only send pk/id
312 | }
313 | ```
314 |
315 | Response
316 | ```js
317 | {
318 | "id": 1,
319 | "price": 40000,
320 | "location": {
321 | "id": 2,
322 | "city": "Tokyo",
323 | "country": "China"
324 | }
325 | }
326 | ```
327 |
328 | !!! note
329 | By default `accept_pk=False` and `accept_pk_only=False`, so nested field(foreign key related) accepts data only by default, if `accept_pk=True` is set, it accepts data and pk/id, and if `accept_pk_only=True` is set it accepts pk/id only. You can't set both `accept_pk=True` and `accept_pk_only=True`.
330 |
331 |
332 | ### create_ops and update_ops kwargs.
333 | These two kwargs are used to restrict some operations when creating or updating nested data. Below is an example showing how to restrict some operations by using these two kwargs.
334 |
335 | ```py
336 | from rest_framework import serializers
337 | from django_restql.fields import NestedField
338 | from django_restql.serializers import NestedModelSerializer
339 |
340 | from app.models import Location, Amenity, Property
341 |
342 |
343 | class AmenitySerializer(serializers.ModelSerializer):
344 | class Meta:
345 | model = Amenity
346 | fields = ["id", "name"]
347 |
348 |
349 | class PropertySerializer(NestedModelSerializer):
350 | amenities = NestedField(
351 | AmenitySerializer,
352 | many=True,
353 | create_ops=["add"], # Allow only add operation
354 | update_ops=["add", "remove"] # Allow only add and remove operations
355 | )
356 | class Meta:
357 | model = Property
358 | fields = [
359 | 'id', 'price', 'amenities'
360 | ]
361 | ```
362 |
363 | Sending create mutation request
364 |
365 | ```POST /api/property/```
366 |
367 | Request Body
368 | ```js
369 | {
370 | "price": 60000,
371 | "amenities": {
372 | "add": [1, 2]
373 | }
374 | }
375 | ```
376 | !!! note
377 | Since `create_ops=["add"]`, you can't use `create` operation in here!.
378 |
379 | Response
380 | ```js
381 | {
382 | "id": 2,
383 | "price": 60000,
384 | "amenities": [
385 | {"id": 1, "name": "Watererr"},
386 | {"id": 2, "name": "Electricity"}
387 | ]
388 | }
389 | ```
390 |
391 | Sending update mutation request
392 |
393 | ```PUT/PATCH /api/property/2/```
394 |
395 | Request Body
396 | ```js
397 | {
398 | "price": 50000,
399 | "amenities": {
400 | "add": [3],
401 | "remove": [2]
402 | }
403 | }
404 | ```
405 | !!! note
406 | Since `update_ops=["add", "remove"]`, you can't use `create` or `update` operation in here!.
407 |
408 | Response
409 | ```js
410 | {
411 | "id": 2,
412 | "price": 50000,
413 | "amenities": [
414 | {"id": 1, "name": "Water"},
415 | {"id": 3, "name": "Bathtub"}
416 | ]
417 | }
418 | ```
419 |
420 |
421 | ### allow_remove_all kwarg
422 | This kwarg is used to enable and disable removing all related objects on many related nested field at once by using `__all__` directive. The default value of `allow_remove_all` is `False`, which means removing all related objects on many related nested fields is disabled by default so if you want to enable it you must set its value to `True`. For example
423 |
424 | ```py
425 | class CourseSerializer(NestedModelSerializer):
426 | books = NestedField(BookSerializer, many=True, allow_remove_all=True)
427 |
428 | class Meta:
429 | model = Course
430 | fields = ["name", "code", "books"]
431 | ```
432 |
433 | With `allow_remove_all=True` as set above you will be able to send a request like
434 |
435 | ```PUT/PATCH /courses/3/```
436 |
437 | Request Body
438 | ```js
439 | {
440 | "books": {
441 | "remove": "__all__"
442 | }
443 | }
444 | ```
445 |
446 | This will remove all books associated with a course being updated.
447 |
448 |
449 |
450 | ## Using DynamicFieldsMixin and NestedField together
451 | You can use `DynamicFieldsMixin` and `NestedModelSerializer` together if you want your serializer to be writable(on nested fields) and support querying data, this is very common. Below is an example which shows how you can use `DynamicFieldsMixin` and `NestedField` together.
452 |
453 | ```py
454 | from rest_framework import serializers
455 | from django_restql.fields import NestedField
456 | from django_restql.mixins import DynamicFieldsMixin
457 | from django_restql.serializers import NestedModelSerializer
458 |
459 | from app.models import Location, Property
460 |
461 |
462 | class LocationSerializer(DynamicFieldsMixin, serializers.ModelSerializer):
463 | class Meta:
464 | model = Location
465 | fields = ["id", "city", "country"]
466 |
467 | # Inherit both DynamicFieldsMixin and NestedModelSerializer
468 | class PropertySerializer(DynamicFieldsMixin, NestedModelSerializer):
469 | location = NestedField(LocationSerializer)
470 | class Meta:
471 | model = Property
472 | fields = [
473 | 'id', 'price', 'location'
474 | ]
475 | ```
476 |
477 | `NestedField` is nothing but a serializer wrapper, it returns an instance of a modified version of a serializer passed, so you can pass all the args and kwargs accepted by a serializer on it, it will simply pass them along to a serializer passed when instantiating an instance. So you can pass anything accepted by a serializer to a `NestedField` wrapper, and if a serializer passed inherits `DynamicFieldsMixin` just like `LocationSerializer` on the example above then you can pass any arg or kwarg accepted by `DynamicFieldsMixin` when defining location as a nested field, i.e
478 |
479 | ```py
480 | location = NestedField(LocationSerializer, fields=[...])
481 | ```
482 |
483 | ```py
484 | location = NestedField(LocationSerializer, exclude=[...])
485 | ```
486 |
487 | ```py
488 | location = NestedField(LocationSerializer, return_pk=True)
489 | ```
490 |
491 |
492 | !!! note
493 | If you want to use `required=False` kwarg on `NestedField` you might want to include `allow_null=True` too if you want your nested field to be set to `null` if you haven't supplied it. For example
494 |
495 |
496 | ```py
497 | from rest_framework import serializers
498 | from django_restql.fields import NestedField
499 | from django_restql.mixins import DynamicFieldsMixin
500 | from django_restql.serializers import NestedModelSerializer
501 |
502 | from app.models import Location, Property
503 |
504 |
505 | class LocationSerializer(serializers.ModelSerializer):
506 | class Meta:
507 | model = Location
508 | fields = ["id", "city", "country"]
509 |
510 |
511 | class PropertySerializer(NestedModelSerializer):
512 | # Passing both `required=False` and `allow_null=True`
513 | location = NestedField(LocationSerializer, required=False, allow_null=True)
514 | class Meta:
515 | model = Property
516 | fields = [
517 | 'id', 'price', 'location'
518 | ]
519 | ```
520 |
521 | The `required=False` kwarg allows you to create Property without including `location` field and the `allow_null=True` kwarg allows `location` field to be set to null if you haven't supplied it. For example
522 |
523 | Sending mutation request
524 |
525 | ```POST /api/property/```
526 |
527 | Request Body
528 | ```js
529 | {
530 | "price": 40000
531 | // You can see that the location is not included here
532 | }
533 | ```
534 |
535 | Response
536 | ```js
537 | {
538 | "id": 2,
539 | "price": 50000,
540 | "location": null // This is the result of not including location
541 | }
542 | ```
543 |
544 | If you use `required=False` only without `allow_null=True`, The serializer will allow you to create Property without including `location` field but it will throw error because by default `allow_null=False` which means `null`/`None`(which is what's passed when you don't supply `location` value) is not considered a valid value.
545 |
546 |
547 | ## Working with data mutation without request
548 | **Django RESTQL** allows you to do data mutation without having request object, this is used if you don't want to get your mutation data input(serializer data) from a request, in fact `NestedModelSerializer` and `NestedFied` can work independently without using request. Below is an example showing how you can work with data mutation without request object.
549 |
550 | ```py
551 | from rest_framework import serializers
552 | from django_restql.fields import NestedField
553 | from django_restql.mixins import DynamicFieldsMixin
554 | from django_restql.serializers import NestedModelSerializer
555 |
556 | from app.models import Book, Course
557 |
558 |
559 | class BookSerializer(DynamicFieldsMixin, NestedModelSerializer):
560 | class Meta:
561 | model = Book
562 | fields = ['id', 'title', 'author']
563 |
564 |
565 | class CourseSerializer(DynamicFieldsMixin, NestedModelSerializer):
566 | books = NestedField(BookSerializer, many=True, required=False)
567 | class Meta:
568 | model = Course
569 | fields = ['id', 'name', 'code', 'books']
570 | ```
571 |
572 | From serializers above you can create a course like
573 |
574 | ```py
575 | data = {
576 | "name": "Computer Programming",
577 | "code": "CS50",
578 | "books": {
579 | "add": [1, 2],
580 | "create": [
581 | {'title': 'Basic Data Structures', 'author': 'J. Davis'},
582 | {'title': 'Advanced Data Structures', 'author': 'S. Mobit'}
583 | ]
584 | }
585 | }
586 |
587 | serializer = CourseSerializer(data=data)
588 | serializer.is_valid()
589 | serializer.save()
590 |
591 | print(serializer.data)
592 |
593 | # This will print
594 | {
595 | "id": 2,
596 | "name": "Computer Programming",
597 | "code": "CS50",
598 | "books": [
599 | {'id': 1, 'title': 'Programming Intro', 'author': 'K. Moses'},
600 | {'id': 2, 'title': 'Understanding Computers', 'author': 'B. Gibson'},
601 | {'id': 3, 'title': 'Basic Data Structures', 'author': 'J. Davis'},
602 | {'id': 4, 'title': 'Advanced Data Structures', 'author': 'S. Mobit'}
603 | ]
604 | }
605 | ```
606 |
607 | To update a created course you can do it like
608 |
609 | ```py
610 | data = {
611 | "code": "CS100",
612 | "books": {
613 | "remove": [2, 3]
614 | }
615 | }
616 |
617 | course_obj = Course.objects.get(pk=2)
618 |
619 | serializer = CourseSerializer(course_obj, data=data)
620 | serializer.is_valid()
621 | serializer.save()
622 |
623 | print(serializer.data)
624 |
625 | # This will print
626 | {
627 | "id": 2,
628 | "name": "Computer Programming",
629 | "code": "CS100",
630 | "books": [
631 | {'id': 1, 'title': 'Programming Intro', 'author': 'K. Moses'},
632 | {'id': 2, 'title': 'Understanding Computers', 'author': 'B. Gibson'}
633 | ]
634 | }
635 | ```
--------------------------------------------------------------------------------
/docs/querying_data.md:
--------------------------------------------------------------------------------
1 | # Querying Data
2 | **Django RESTQL** makes data querying(selecting fields to include in a response) way easier, if you want to use it to query data you just have to inherit the `DynamicFieldsMixin` class when defining your serializer, that's all. Below is an example showing how to use `DynamicFieldsMixin`.
3 | ```py
4 | from rest_framework import serializers
5 | from django.contrib.auth.models import User
6 | from django_restql.mixins import DynamicFieldsMixin
7 |
8 |
9 | class UserSerializer(DynamicFieldsMixin, serializer.ModelSerializer):
10 | class Meta:
11 | model = User
12 | fields = ['id', 'username', 'email']
13 | ```
14 |
15 | Here a regular request returns all fields as specified on a DRF serializer, in fact **Django RESTQL** doesn't handle this(regular) request at all. Below is an example of a regular request and its response
16 |
17 | `GET /users`
18 |
19 | ```js
20 | [
21 | {
22 | "id": 1,
23 | "username": "yezyilomo",
24 | "email": "yezileliilomo@hotmail.com",
25 | },
26 | ...
27 | ]
28 | ```
29 |
30 | As you can see all fields have been returned as specified on `UserSerializer`.
31 |
32 | **Django RESTQL** handle all requests with a `query` parameter, this parameter is the one which is used to pass all fields to be included/excluded in a response.
33 |
34 | For example to select `id` and `username` fields from User model, send a request with a ` query` parameter as shown below.
35 |
36 | `GET /users/?query={id, username}`
37 | ```js
38 | [
39 | {
40 | "id": 1,
41 | "username": "yezyilomo"
42 | },
43 | ...
44 | ]
45 | ```
46 | You can see only `id` and `username` fields have been returned in a response as specified on a `query` parameter.
47 |
48 |
49 | ## Querying nested fields
50 | **Django RESTQL** support querying both flat and nested data, so you can expand or query nested fields at any level as defined on a serializer. In an example below we have `location` and `groups` as nested fields on User model.
51 |
52 | ```py
53 | from rest_framework import serializers
54 | from django.contrib.auth.models import User
55 | from django_restql.mixins import DynamicFieldsMixin
56 |
57 | from app.models import GroupSerializer, LocationSerializer
58 |
59 |
60 | class GroupSerializer(DynamicFieldsMixin, serializer.ModelSerializer):
61 | class Meta:
62 | model = Group
63 | fields = ['id', 'name']
64 |
65 |
66 | class LocationSerializer(DynamicFieldsMixin, serializer.ModelSerializer):
67 | class Meta:
68 | model = Location
69 | fields = ['id', 'country', 'city', 'street']
70 |
71 |
72 | class UserSerializer(DynamicFieldsMixin, serializer.ModelSerializer):
73 | groups = GroupSerializer(many=True, read_only=True)
74 | location = LocationSerializer(many=False, read_only=True)
75 | class Meta:
76 | model = User
77 | fields = ['id', 'username', 'email', 'location', 'groups']
78 | ```
79 |
80 | If you want to retrieve user's `id`, `username` and `location` fields but under `location` field you want to get only `country` and `city` fields here is how you can do it
81 |
82 | `GET /users/?query={id, username, location{country, city}}`
83 | ```js
84 | [
85 | {
86 | "id": 1,
87 | "username": "yezyilomo",
88 | "location": {
89 | "contry": "Tanzania",
90 | "city": "Dar es salaam"
91 | }
92 | },
93 | ...
94 | ]
95 | ```
96 |
97 |