├── tests
├── __init__.py
├── testapp
│ ├── apps.py
│ ├── models.py
│ ├── views.py
│ └── serializers.py
├── manage.py
├── urls.py
└── settings.py
├── docs
├── CNAME
├── extra.css
├── img
│ ├── github.svg
│ ├── favicon.svg
│ └── icon.svg
├── license.md
├── settings.md
├── index.md
├── mutating_data.md
└── querying_data.md
├── .github
├── FUNDING.yml
└── workflows
│ └── main.yml
├── django_restql
├── operations.py
├── serializers.py
├── exceptions.py
├── __init__.py
├── settings.py
├── parser.py
├── fields.py
└── mixins.py
├── requirements.txt
├── pyproject.toml
├── mkdocs.yml
├── runtests.py
├── LICENSE
├── tox.ini
├── CONTRIBUTING.md
├── setup.py
├── .gitignore
└── README.md
/tests/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs/CNAME:
--------------------------------------------------------------------------------
1 | yezyilomo.github.io/django-restql
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | patreon: yezyilomo
2 | open_collective: django-restql
3 |
--------------------------------------------------------------------------------
/django_restql/operations.py:
--------------------------------------------------------------------------------
1 | ADD = "add"
2 | CREATE = "create"
3 | REMOVE = "remove"
4 | UPDATE = "update"
5 | DELETE = "delete"
6 |
--------------------------------------------------------------------------------
/tests/testapp/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 |
3 |
4 | class TestappConfig(AppConfig):
5 | name = "tests.testapp"
6 | default_auto_field = "django.db.models.BigAutoField"
7 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | # Required
2 | pypeg2>=2.15.2
3 |
4 | # Don't include these two below as they are already included in tox.ini
5 | # Django>=1.11
6 | # djangorestframework>=3.5
7 |
8 | # Optional
9 | django-filter
10 |
11 | # Code style
12 | flake8>=3.7.9
13 | flake8-tidy-imports>=4.1.0
14 | pycodestyle>=2.5.0
--------------------------------------------------------------------------------
/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/__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.18.0"
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 |
--------------------------------------------------------------------------------
/docs/extra.css:
--------------------------------------------------------------------------------
1 | .md-header {
2 | height: 53px;
3 | padding-top: 2px;
4 | }
5 |
6 | .md-search__form,
7 | .md-search__output {
8 | border-radius: 8px !important;
9 | }
10 |
11 | .md-search__output {
12 | margin-top: 3px !important;
13 | }
14 |
15 | .md-source__icon svg {
16 | display: none;
17 | }
18 |
19 | .md-source__icon {
20 | background-image: url("img/github.svg");
21 | background-repeat: no-repeat;
22 | background-position: 50% center;
23 | background-size: 22px;
24 | }
25 |
26 | .md-code__content,
27 | .note {
28 | border-radius: 10px !important;
29 | }
--------------------------------------------------------------------------------
/tests/manage.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | import os
3 | import sys
4 |
5 | if __name__ == "__main__":
6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings")
7 | try:
8 | from django.core.management import execute_from_command_line
9 | except ImportError as exc:
10 | raise ImportError(
11 | "Couldn't import Django. Are you sure it's installed and "
12 | "available on your PYTHONPATH environment variable? Did you "
13 | "forget to activate a virtual environment?"
14 | ) from exc
15 | execute_from_command_line(sys.argv)
16 |
--------------------------------------------------------------------------------
/.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
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [tool.isort]
2 | line_length = 90
3 | length_sort = true
4 | multi_line_output = 5
5 | use_parentheses = true
6 | lines_after_imports = 2
7 | force_sort_within_sections = true
8 | # For more confits visit https://pycqa.github.io/isort/docs/configuration/options.html
9 |
10 |
11 | [tool.autopep8]
12 | max_line_length = 80
13 | ignore = ["E266", "E501", "W503", "W504", "E704", "W505", "W6", "E402"]
14 | # E266 Too many leading # for block comment
15 | # E501 Line too long (80 > 79 characters)
16 | # W503 Line break before binary operator
17 | # W504 Line break after binary operator
18 | # E704 Multiple statements on one line (def)
19 | # W505 doc line too long (80 > 79 characters)
20 | # E402 All import statements at the top of a file
--------------------------------------------------------------------------------
/docs/img/github.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/mkdocs.yml:
--------------------------------------------------------------------------------
1 | site_name: Django RESTQL
2 |
3 | theme:
4 | name: "material"
5 | language: en
6 | features:
7 | - content.code.copy
8 | palette:
9 | primary: teal
10 | accent: teal
11 | font:
12 | text: Roboto
13 | code: Roboto Mono
14 | logo: img/icon.svg
15 | favicon: img/favicon.svg
16 | extra_css: [extra.css]
17 | repo_name: yezyilomo/django-restql
18 | repo_url: https://github.com/yezyilomo/django-restql/
19 |
20 | # Extensions
21 | markdown_extensions:
22 | - pymdownx.highlight:
23 | anchor_linenums: true
24 | line_spans: __span
25 | pygments_lang_class: true
26 | - pymdownx.inlinehilite
27 | - pymdownx.snippets
28 | - pymdownx.superfences
29 | - admonition
30 | - toc:
31 | permalink: true
32 |
33 |
34 | nav:
35 | - Intro: index.md
36 | - Querying Data: querying_data.md
37 | - Mutating Data: mutating_data.md
38 | - Settings: settings.md
39 | - License: license.md
--------------------------------------------------------------------------------
/runtests.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | import os
3 | import sys
4 | import subprocess
5 |
6 | from django.core.management import execute_from_command_line
7 |
8 |
9 | FLAKE8_ARGS = ["django_restql", "tests", "setup.py", "runtests.py"]
10 | WARNING_COLOR = "\033[93m"
11 | END_COLOR = "\033[0m"
12 |
13 |
14 | def flake8_main(args):
15 | print("Running flake8 code linting")
16 | ret = subprocess.call(["flake8"] + args)
17 | msg = (
18 | WARNING_COLOR + "flake8 failed\n" + END_COLOR
19 | if ret else "flake8 passed\n"
20 | )
21 | print(msg)
22 | return ret
23 |
24 |
25 | def runtests():
26 | ret = flake8_main(FLAKE8_ARGS)
27 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings")
28 | argv = sys.argv[:1] + ["test"] + sys.argv[1:]
29 | execute_from_command_line(argv)
30 | sys.exit(ret) # Fail build if code linting fails
31 |
32 |
33 | if __name__ == "__main__":
34 | runtests()
35 |
--------------------------------------------------------------------------------
/docs/img/favicon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
8 |
--------------------------------------------------------------------------------
/docs/img/icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
8 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/tox.ini:
--------------------------------------------------------------------------------
1 | # content of: tox.ini , put in same dir as setup.py
2 |
3 | [tox]
4 | envlist =
5 | py{37}-dj{111}-drf{35,36,37,38,39,310,311}
6 | py{37}-dj{20,21,22}-drf{37,38,39,310,311}
7 | py{38,39}-dj{22}-drf{37,38,39,310,311,312}
8 | py{36,37,38,39}-dj{30}-drf{310,311,312}
9 | py{36,37,38,39,310}-dj{31,32}-drf{311,312,313,314}
10 | py{38,39,310,311,312}-dj{40,41}-drf{313,314}
11 |
12 | [gh-actions]
13 | python =
14 | 3.7: py37
15 | 3.8: py38
16 | 3.9: py39
17 | 3.10: py310
18 | 3.11: py311
19 | 3.12: py312
20 |
21 | DJANGO =
22 | 1.11: dj111
23 | 2.0: dj20
24 | 2.1: dj21
25 | 2.2: dj22
26 | 3.0: dj30
27 | 3.1: dj31
28 | 3.2: dj32
29 | 4.0: dj40
30 | 4.1: dj41
31 |
32 | [testenv]
33 | commands = python runtests.py
34 | deps =
35 | dj111: Django>=1.11,<2.0
36 | dj20: Django>=2.0,<2.1
37 | dj21: Django>=2.1,<2.2
38 | dj22: Django>=2.2,<3.0
39 | dj30: Django>=3.0,<3.1
40 | dj31: Django>=3.1,<3.2
41 | dj32: Django>=3.2,<3.3
42 | dj40: Django>=4.0,<4.1
43 | dj41: Django>=4.1,<4.2
44 | drf35: djangorestframework>=3.5,<3.6
45 | drf36: djangorestframework>=3.6.0,<3.7
46 | drf37: djangorestframework>=3.7.0,<3.8
47 | drf38: djangorestframework>=3.8.0,<3.9
48 | drf39: djangorestframework>=3.9.0,<3.10
49 | drf310: djangorestframework>=3.10,<3.11
50 | drf311: djangorestframework>=3.11,<3.12
51 | drf312: djangorestframework>=3.12,<3.13
52 | drf313: djangorestframework>=3.13,<3.14
53 | drf314: djangorestframework>=3.14,<3.15
54 | -rrequirements.txt
55 |
56 | [flake8]
57 | ignore = E266, E501, W503, W504, E704, W505
58 | # E266 Too many leading ‘#’ for block comment
59 | # E501 Line too long (82 > 79 characters)
60 | # W503 Line break before binary operator
61 | # W504 Line break after binary operator
62 | # E704 Multiple statements on one line (def)
63 | # W505 doc line too long (82 > 79 characters)
--------------------------------------------------------------------------------
/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/).
--------------------------------------------------------------------------------
/tests/testapp/models.py:
--------------------------------------------------------------------------------
1 | from django.db import models
2 | from django.contrib.contenttypes.models import ContentType
3 | from django.contrib.contenttypes.fields import GenericRelation, GenericForeignKey
4 |
5 |
6 | class Genre(models.Model):
7 | title = models.CharField(max_length=50)
8 | description = models.TextField()
9 |
10 |
11 | class Book(models.Model):
12 | title = models.CharField(max_length=50)
13 | author = models.CharField(max_length=50)
14 | genres = models.ManyToManyField(Genre, blank=True, related_name="books")
15 |
16 |
17 | class Instructor(models.Model):
18 | name = models.CharField(max_length=50)
19 |
20 |
21 | class Course(models.Model):
22 | name = models.CharField(max_length=50)
23 | code = models.CharField(max_length=30)
24 | books = models.ManyToManyField(Book, blank=True, related_name="courses")
25 | instructor = models.ForeignKey(
26 | Instructor,
27 | blank=True,
28 | null=True,
29 | on_delete=models.CASCADE,
30 | related_name="courses",
31 | )
32 |
33 |
34 | class Student(models.Model):
35 | name = models.CharField(max_length=50)
36 | age = models.IntegerField()
37 | course = models.ForeignKey(
38 | Course, blank=True, null=True, on_delete=models.CASCADE, related_name="students"
39 | )
40 | study_partner = models.OneToOneField(
41 | "self", blank=True, null=True, on_delete=models.CASCADE
42 | )
43 | sport_partners = models.ManyToManyField("self", blank=True)
44 |
45 |
46 | class Phone(models.Model):
47 | number = models.CharField(max_length=15)
48 | type = models.CharField(max_length=50)
49 | student = models.ForeignKey(
50 | Student, blank=True, null=True, on_delete=models.CASCADE, related_name="phone_numbers"
51 | )
52 |
53 |
54 | class Attachment(models.Model):
55 | content = models.TextField()
56 | object_id = models.PositiveIntegerField(null=True)
57 | content_type = models.ForeignKey(ContentType, null=True, on_delete=models.CASCADE)
58 | document = GenericForeignKey("content_type", "object_id")
59 |
60 |
61 | class Post(models.Model):
62 | title = models.CharField(max_length=50)
63 | content = models.TextField()
64 | attachments = GenericRelation(Attachment, related_query_name="post")
65 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | import os
2 | import re
3 | import sys
4 | from codecs import open
5 |
6 | from setuptools import find_packages, setup
7 |
8 | # "setup.py publish" shortcut.
9 | if sys.argv[-1] == "publish":
10 | os.system("python3 setup.py sdist bdist_wheel")
11 | os.system("twine upload dist/*")
12 | sys.exit()
13 |
14 |
15 | def get_readme():
16 | readme = ""
17 | with open("README.md", "r", "utf-8") as f:
18 | readme = f.read()
19 | return readme
20 |
21 |
22 | def get_info(info_name):
23 | init_py = open(os.path.join("django_restql", "__init__.py")).read()
24 | return re.search("%s = ['\"]([^'\"]+)['\"]" % info_name, init_py).group(1)
25 |
26 |
27 | url = get_info("__url__")
28 | version = get_info("__version__")
29 | license_ = get_info("__license__")
30 | description = get_info("__description__")
31 | author = get_info("__author__")
32 | author_email = get_info("__author_email__")
33 | readme = get_readme()
34 |
35 | setup(
36 | name="django-restql",
37 | version=version,
38 | description=description,
39 | long_description=readme,
40 | long_description_content_type="text/markdown",
41 | url=url,
42 | author=author,
43 | author_email=author_email,
44 | license=license_,
45 | packages=find_packages(exclude=("tests", "test")),
46 | package_data={"": ["LICENSE"]},
47 | install_requires=[
48 | "pypeg2>=2.15.2",
49 | "django>=1.11",
50 | "djangorestframework>=3.5"
51 | ],
52 | python_requires=">=3.5",
53 | classifiers=[
54 | "Development Status :: 5 - Production/Stable",
55 | "Intended Audience :: Developers",
56 | "Natural Language :: English",
57 | "License :: OSI Approved :: MIT License",
58 | "Framework :: Django",
59 | "Framework :: Django :: 1.11",
60 | "Framework :: Django :: 2.0",
61 | "Framework :: Django :: 2.1",
62 | "Framework :: Django :: 2.2",
63 | "Framework :: Django :: 3.0",
64 | "Framework :: Django :: 3.1",
65 | "Framework :: Django :: 3.2",
66 | "Framework :: Django :: 4.0",
67 | "Framework :: Django :: 4.1",
68 | "Programming Language :: Python",
69 | "Programming Language :: Python :: 3.6",
70 | "Programming Language :: Python :: 3.7",
71 | "Programming Language :: Python :: 3.8",
72 | "Programming Language :: Python :: 3.9",
73 | "Programming Language :: Python :: 3.10",
74 | "Programming Language :: Python :: 3.11",
75 | "Programming Language :: Python :: 3.12",
76 | ],
77 | test_suite="runtests",
78 | )
79 |
--------------------------------------------------------------------------------
/docs/settings.md:
--------------------------------------------------------------------------------
1 | # Settings
2 | Configuration for **Django RESTQL** is all namespaced inside a single Django setting named `RESTQL`, below is a list of what you can configure under `RESTQL` setting.
3 |
4 | ## QUERY_PARAM_NAME
5 | The default value for this is `query`. If you don't want to use the name `query` as your parameter, you can change it with`QUERY_PARAM_NAME` on settings file e.g
6 | ```py
7 | # settings.py file
8 | RESTQL = {
9 | "QUERY_PARAM_NAME": "your_favourite_name"
10 | }
11 | ```
12 | Now you can use the name `your_favourite_name` as your query parameter. E.g
13 |
14 | `GET /users/?your_favourite_name={id, username}`
15 |
16 | ## MAX_ALIAS_LEN
17 | The default value for this is 50. When creating aliases this setting limit the number of characters allowed in aliases. This setting prevents DoS like attacks to API which might be caused by clients specifying a really really long alias which might increase network usage. If you want to change the default value, do as follows
18 |
19 | ```py
20 | # settings.py file
21 | RESTQL = {
22 | "MAX_ALIAS_LEN": 100 # Put the value that you want here
23 | }
24 | ```
25 |
26 | ## AUTO_APPLY_EAGER_LOADING
27 | The default value for this is `True`. When using the `EagerLoadingMixin`, this setting controls if the mappings for `select_related` and `prefetch_related` are applied automatically when calling `get_queryset`. To turn it off, set the `AUTO_APPLY_EAGER_LOADING` setting or `auto_apply_eager_loading` attribute on the view to `False`.
28 | ```py
29 | # settings.py file
30 | # This will turn off auto apply eager loading globally
31 | RESTQL = {
32 | "AUTO_APPLY_EAGER_LOADING": False
33 | }
34 | ```
35 |
36 | If auto apply eager loading is turned off, the method `apply_eager_loading` can still be used on your queryset if you wish to select or prefetch related fields according to your conditions, For example you can check if there was a query parameter passed in by using `has_restql_query_param`, if true then apply eager loading otherwise return a normal queryset.
37 | ```py
38 | from rest_framework import viewsets
39 | from django_restql.mixins import EagerLoadingMixin
40 | from myapp.serializers import StudentSerializer
41 | from myapp.models import Student
42 |
43 | class StudentViewSet(EagerLoadingMixin, viewsets.ModelViewSet):
44 | serializer_class = StudentSerializer
45 | queryset = Student.objects.all()
46 |
47 | # Turn off auto apply eager loading per view
48 | # This overrides the `AUTO_APPLY_EAGER_LOADING` setting on this view
49 | auto_apply_eager_loading = False
50 | select_related = {
51 | "program": "course"
52 | }
53 | prefetch_related = {
54 | "program.books": "course__books"
55 | }
56 |
57 | def get_queryset(self):
58 | queryset = super().get_queryset()
59 | if self.has_restql_query_param:
60 | queryset = self.apply_eager_loading(queryset)
61 | return queryset
62 | ```
63 |
--------------------------------------------------------------------------------
/.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
--------------------------------------------------------------------------------
/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
6 |
7 | * Control the data you get on client side.
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 |
--------------------------------------------------------------------------------
/tests/urls.py:
--------------------------------------------------------------------------------
1 | """test_app URL Configuration
2 |
3 | The `urlpatterns` list routes URLs to views. For more information please see:
4 | https://docs.djangoproject.com/en/2.1/topics/http/urls/
5 | Examples:
6 | Function views
7 | 1. Add an import: from my_app import views
8 | 2. Add a URL to urlpatterns: path("", views.home, name="home")
9 | Class-based views
10 | 1. Add an import: from other_app.views import Home
11 | 2. Add a URL to urlpatterns: path("", Home.as_view(), name="home")
12 | Including another URLconf
13 | 1. Import the include() function: from django.urls import include, path
14 | 2. Add a URL to urlpatterns: path("blog/", include("blog.urls"))
15 | """
16 |
17 | try:
18 | # For django <= 3.x
19 | from django.conf.urls import include, url as path
20 | except ImportError:
21 | from django.urls import include, path
22 |
23 | from tests.testapp import views
24 |
25 | from rest_framework import routers
26 |
27 | router = routers.DefaultRouter()
28 |
29 | router.register("books", views.BookViewSet, "book")
30 | router.register("courses", views.CourseViewSet, "course")
31 | router.register(
32 | "courses-with-disable-dynamic-fields",
33 | views.CourseWithDisableDaynamicFieldsKwargViewSet,
34 | "course_with_disable_dynamic_fields_kwarg",
35 | )
36 | router.register(
37 | "courses-with-returnpk-kwarg",
38 | views.CourseWithReturnPkkwargViewSet,
39 | "course_with_returnpk_kwarg",
40 | )
41 | router.register(
42 | "courses-with-field-kwarg",
43 | views.CourseWithFieldsKwargViewSet,
44 | "course_with_field_kwarg",
45 | )
46 | router.register(
47 | "courses-with-exclude-kwarg",
48 | views.CourseWithExcludeKwargViewSet,
49 | "course_with_exclude_kwarg",
50 | )
51 | router.register(
52 | "courses-with-aliased-books",
53 | views.CourseWithAliasedBooksViewSet,
54 | "course_with_aliased_books",
55 | )
56 | router.register(
57 | "course-with-dynamic-serializer-method-field",
58 | views.CourseWithDynamicSerializerMethodFieldViewSet,
59 | "course_with_dynamic_serializer_method_field",
60 | )
61 | router.register("students", views.StudentViewSet, "student")
62 | router.register(
63 | "students-eager-loading", views.StudentEagerLoadingViewSet, "student_eager_loading"
64 | )
65 | router.register(
66 | "students-eager-loading-prefetch",
67 | views.StudentEagerLoadingPrefetchObjectViewSet,
68 | "student_eager_loading_prefetch",
69 | )
70 | router.register(
71 | "students-auto-apply-eager-loading",
72 | views.StudentAutoApplyEagerLoadingViewSet,
73 | "student_auto_apply_eager_loading",
74 | )
75 |
76 | router.register("writable-courses", views.WritableCourseViewSet, "wcourse")
77 | router.register("replaceable-students", views.ReplaceableStudentViewSet, "rstudent")
78 | router.register(
79 | "replaceable-students-with-alias",
80 | views.ReplaceableStudentWithAliasViewSet,
81 | "rstudent_with_alias",
82 | )
83 | router.register("writable-students", views.WritableStudentViewSet, "wstudent")
84 | router.register(
85 | "writable-students-with-alias",
86 | views.WritableStudentWithAliasViewSet,
87 | "wstudent_with_alias",
88 | )
89 | router.register("posts", views.PostViewSet, "post")
90 |
91 | urlpatterns = [path("", include(router.urls))]
92 |
--------------------------------------------------------------------------------
/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 |
16 | DEFAULTS = {
17 | "QUERY_PARAM_NAME": "query",
18 | "AUTO_APPLY_EAGER_LOADING": True,
19 | "MAX_ALIAS_LEN": 50
20 | }
21 |
22 |
23 | # List of settings that may be in string import notation.
24 | IMPORT_STRINGS = [
25 |
26 | ]
27 |
28 |
29 | def perform_import(val, setting_name):
30 | """
31 | If the given setting is a string import notation,
32 | then perform the necessary import or imports.
33 | """
34 | if val is None:
35 | return None
36 | elif isinstance(val, str):
37 | return import_from_string(val, setting_name)
38 | elif isinstance(val, (list, tuple)):
39 | return [import_from_string(item, setting_name) for item in val]
40 | return val
41 |
42 |
43 | def import_from_string(val, setting_name):
44 | """
45 | Attempt to import a class from a string representation.
46 | """
47 | try:
48 | return import_string(val)
49 | except ImportError as e:
50 | msg = (
51 | "Could not import `%s` for RESTQL setting `%s`. %s: %s."
52 | ) % (val, setting_name, e.__class__.__name__, e)
53 | raise ImportError(msg)
54 |
55 |
56 | class RESTQLSettings:
57 | """
58 | A settings object, that allows RESTQL settings to be accessed as properties.
59 | For example:
60 | from django_restql.settings import restql_settings
61 | print(restql_settings.QUERY_PARAM_NAME)
62 | Any setting with string import paths will be automatically resolved
63 | and return the class, rather than the string literal.
64 | """
65 |
66 | def __init__(self, user_settings=None, defaults=None, import_strings=None):
67 | self.defaults = defaults or DEFAULTS
68 | self.import_strings = import_strings or IMPORT_STRINGS
69 | self._cached_attrs = set()
70 |
71 | @property
72 | def user_settings(self):
73 | if not hasattr(self, "_user_settings"):
74 | self._user_settings = getattr(settings, "RESTQL", {})
75 | return self._user_settings
76 |
77 | def __getattr__(self, attr):
78 | if attr not in self.defaults:
79 | raise AttributeError("Invalid RESTQL setting: '%s'" % attr)
80 |
81 | try:
82 | # Check if present in user settings
83 | val = self.user_settings[attr]
84 | except KeyError:
85 | # Fall back to defaults
86 | val = self.defaults[attr]
87 |
88 | # Coerce import strings into classes
89 | if attr in self.import_strings:
90 | val = perform_import(val, attr)
91 |
92 | # Cache the result
93 | self._cached_attrs.add(attr)
94 | setattr(self, attr, val)
95 | return val
96 |
97 | def reload(self):
98 | for attr in self._cached_attrs:
99 | delattr(self, attr)
100 | self._cached_attrs.clear()
101 | if hasattr(self, "_user_settings"):
102 | delattr(self, "_user_settings")
103 |
104 |
105 | restql_settings = RESTQLSettings(None, DEFAULTS, IMPORT_STRINGS)
106 |
107 |
108 | def reload_restql_settings(*args, **kwargs):
109 | setting = kwargs["setting"]
110 | if setting == "RESTQL":
111 | restql_settings.reload()
112 |
113 |
114 | setting_changed.connect(reload_restql_settings)
115 |
--------------------------------------------------------------------------------
/tests/settings.py:
--------------------------------------------------------------------------------
1 | """
2 | Django settings for test project.
3 |
4 | Generated by "django-admin startproject" using Django 2.1.2.
5 |
6 | For more information on this file, see
7 | https://docs.djangoproject.com/en/2.1/topics/settings/
8 |
9 | For the full list of settings and their values, see
10 | https://docs.djangoproject.com/en/2.1/ref/settings/
11 | """
12 |
13 | import os
14 |
15 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...)
16 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
17 |
18 |
19 | # Quick-start development settings - unsuitable for production
20 | # See https://docs.djangoproject.com/en/2.1/howto/deployment/checklist/
21 |
22 | # SECURITY WARNING: keep the secret key used in production secret!
23 | SECRET_KEY = "*4&_zzuz$4#@jg-2(ygpo_jvxw^(m7b2ykg&_3h6!@qs^y2e_="
24 |
25 | # SECURITY WARNING: don't run with debug turned on in production!
26 | DEBUG = True
27 |
28 | ALLOWED_HOSTS = []
29 |
30 |
31 | # Application definition
32 |
33 | INSTALLED_APPS = [
34 | "django.contrib.admin",
35 | "django.contrib.auth",
36 | "django.contrib.contenttypes",
37 | "django.contrib.sessions",
38 | "django.contrib.messages",
39 | "django.contrib.staticfiles",
40 | "django_filters",
41 | "rest_framework",
42 | "django_restql",
43 | "tests.testapp",
44 | ]
45 |
46 | MIDDLEWARE = [
47 | "django.middleware.security.SecurityMiddleware",
48 | "django.contrib.sessions.middleware.SessionMiddleware",
49 | "django.middleware.common.CommonMiddleware",
50 | "django.middleware.csrf.CsrfViewMiddleware",
51 | "django.contrib.auth.middleware.AuthenticationMiddleware",
52 | "django.contrib.messages.middleware.MessageMiddleware",
53 | "django.middleware.clickjacking.XFrameOptionsMiddleware",
54 | ]
55 |
56 | ROOT_URLCONF = "tests.urls"
57 |
58 | TEMPLATES = [
59 | {
60 | "BACKEND": "django.template.backends.django.DjangoTemplates",
61 | "DIRS": [],
62 | "APP_DIRS": True,
63 | "OPTIONS": {
64 | "context_processors": [
65 | "django.template.context_processors.debug",
66 | "django.template.context_processors.request",
67 | "django.contrib.auth.context_processors.auth",
68 | "django.contrib.messages.context_processors.messages",
69 | ],
70 | },
71 | },
72 | ]
73 |
74 |
75 | # Database
76 | # https://docs.djangoproject.com/en/2.1/ref/settings/#databases
77 |
78 | DATABASES = {
79 | "default": {
80 | "ENGINE": "django.db.backends.sqlite3",
81 | "NAME": os.path.join(BASE_DIR, "db.sqlite3"),
82 | }
83 | }
84 |
85 |
86 | # REST Framework Settings
87 | REST_FRAMEWORK = {
88 | "DEFAULT_FILTER_BACKENDS": ("django_filters.rest_framework.DjangoFilterBackend",)
89 | }
90 |
91 |
92 | # Password validation
93 | # https://docs.djangoproject.com/en/2.1/ref/settings/#auth-password-validators
94 |
95 | AUTH_PASSWORD_VALIDATORS = [
96 | {
97 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
98 | },
99 | {
100 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
101 | },
102 | {
103 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
104 | },
105 | {
106 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
107 | },
108 | ]
109 |
110 |
111 | # Internationalization
112 | # https://docs.djangoproject.com/en/2.1/topics/i18n/
113 |
114 | LANGUAGE_CODE = "en-us"
115 |
116 | TIME_ZONE = "UTC"
117 |
118 | USE_I18N = True
119 |
120 | USE_L10N = True
121 |
122 | USE_TZ = True
123 |
124 |
125 | # Static files (CSS, JavaScript, Images)
126 | # https://docs.djangoproject.com/en/2.1/howto/static-files/
127 |
128 | STATIC_URL = "/static/"
129 |
--------------------------------------------------------------------------------
/tests/testapp/views.py:
--------------------------------------------------------------------------------
1 | from rest_framework import viewsets
2 | from django.db.models import Prefetch
3 |
4 | from tests.testapp.models import Book, Post, Phone, Course, Student
5 | from django_restql.mixins import EagerLoadingMixin, QueryArgumentsMixin
6 | from tests.testapp.serializers import (
7 | BookSerializer, PostSerializer, CourseSerializer, StudentSerializer,
8 | WritableCourseSerializer, WritableStudentSerializer, StudentWithAliasSerializer,
9 | ReplaceableStudentSerializer, CourseWithFieldsKwargSerializer,
10 | CourseWithAliasedBooksSerializer, CourseWithExcludeKwargSerializer,
11 | CourseWithReturnPkkwargSerializer, WritableStudentWithAliasSerializer,
12 | ReplaceableStudentWithAliasSerializer, CourseWithDynamicSerializerMethodField,
13 | CourseWithDisableDynamicFieldsKwargSerializer
14 | )
15 |
16 |
17 | #### ViewSets for Data Querying And Mutations Testing ####
18 | class BookViewSet(viewsets.ModelViewSet):
19 | serializer_class = BookSerializer
20 | queryset = Book.objects.all()
21 |
22 |
23 | class CourseViewSet(viewsets.ModelViewSet):
24 | serializer_class = CourseSerializer
25 | queryset = Course.objects.all()
26 |
27 |
28 | ############# ViewSets For Data Querying Testing #############
29 | class CourseWithDisableDaynamicFieldsKwargViewSet(viewsets.ModelViewSet):
30 | serializer_class = CourseWithDisableDynamicFieldsKwargSerializer
31 | queryset = Course.objects.all()
32 |
33 |
34 | class CourseWithReturnPkkwargViewSet(viewsets.ModelViewSet):
35 | serializer_class = CourseWithReturnPkkwargSerializer
36 | queryset = Course.objects.all()
37 |
38 |
39 | class CourseWithFieldsKwargViewSet(viewsets.ModelViewSet):
40 | serializer_class = CourseWithFieldsKwargSerializer
41 | queryset = Course.objects.all()
42 |
43 |
44 | class CourseWithExcludeKwargViewSet(viewsets.ModelViewSet):
45 | serializer_class = CourseWithExcludeKwargSerializer
46 | queryset = Course.objects.all()
47 |
48 |
49 | class CourseWithAliasedBooksViewSet(viewsets.ModelViewSet):
50 | serializer_class = CourseWithAliasedBooksSerializer
51 | queryset = Course.objects.all()
52 |
53 |
54 | class CourseWithDynamicSerializerMethodFieldViewSet(viewsets.ModelViewSet):
55 | serializer_class = CourseWithDynamicSerializerMethodField
56 | queryset = Course.objects.all()
57 |
58 |
59 | class StudentViewSet(QueryArgumentsMixin, viewsets.ModelViewSet):
60 | serializer_class = StudentSerializer
61 | queryset = Student.objects.all()
62 |
63 | # For django-filter <=21.1
64 | filter_fields = {
65 | "name": ["exact"],
66 | "age": ["exact"],
67 | "course__name": ["exact"],
68 | "course__code": ["exact"],
69 | "course__books__title": ["exact"],
70 | "course__books__author": ["exact"],
71 | }
72 |
73 | # For django-filter > 21.1
74 | filterset_fields = {
75 | "name": ["exact"],
76 | "age": ["exact"],
77 | "course__name": ["exact"],
78 | "course__code": ["exact"],
79 | "course__books__title": ["exact"],
80 | "course__books__author": ["exact"],
81 | }
82 |
83 |
84 | class StudentEagerLoadingViewSet(EagerLoadingMixin, viewsets.ModelViewSet):
85 | serializer_class = StudentWithAliasSerializer
86 | queryset = Student.objects.all()
87 | select_related = {"program": "course"}
88 | prefetch_related = {
89 | "phone_numbers": "phone_numbers",
90 | "program.books": "course__books",
91 | }
92 |
93 |
94 | class StudentEagerLoadingPrefetchObjectViewSet(
95 | EagerLoadingMixin, viewsets.ModelViewSet
96 | ):
97 | serializer_class = StudentWithAliasSerializer
98 | queryset = Student.objects.all()
99 | select_related = {"program": "course"}
100 | prefetch_related = {
101 | "phone_numbers": [
102 | Prefetch("phone_numbers", queryset=Phone.objects.all()),
103 | ],
104 | "program.books": Prefetch("course__books", queryset=Book.objects.all()),
105 | }
106 |
107 |
108 | class StudentAutoApplyEagerLoadingViewSet(EagerLoadingMixin, viewsets.ModelViewSet):
109 | serializer_class = StudentWithAliasSerializer
110 | queryset = Student.objects.all()
111 | auto_apply_eager_loading = False
112 | select_related = {"program": "course"}
113 | prefetch_related = {
114 | "phone_numbers": [
115 | Prefetch("phone_numbers", queryset=Phone.objects.all()),
116 | ],
117 | "program.books": Prefetch("course__books", queryset=Book.objects.all()),
118 | }
119 |
120 |
121 | ######### ViewSets For Data Mutations Testing ##########
122 | class WritableCourseViewSet(viewsets.ModelViewSet):
123 | serializer_class = WritableCourseSerializer
124 | queryset = Course.objects.all()
125 |
126 |
127 | class ReplaceableStudentViewSet(viewsets.ModelViewSet):
128 | serializer_class = ReplaceableStudentSerializer
129 | queryset = Student.objects.all()
130 |
131 |
132 | class ReplaceableStudentWithAliasViewSet(viewsets.ModelViewSet):
133 | serializer_class = ReplaceableStudentWithAliasSerializer
134 | queryset = Student.objects.all()
135 |
136 |
137 | class WritableStudentViewSet(viewsets.ModelViewSet):
138 | serializer_class = WritableStudentSerializer
139 | queryset = Student.objects.all()
140 |
141 |
142 | class WritableStudentWithAliasViewSet(viewsets.ModelViewSet):
143 | serializer_class = WritableStudentWithAliasSerializer
144 | queryset = Student.objects.all()
145 |
146 |
147 | class PostViewSet(viewsets.ModelViewSet):
148 | serializer_class = PostSerializer
149 | queryset = Post.objects.all()
150 |
--------------------------------------------------------------------------------
/django_restql/parser.py:
--------------------------------------------------------------------------------
1 | import re
2 | from collections import namedtuple
3 |
4 | from pypeg2 import List, csl, name, parse, optional, contiguous
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 |
--------------------------------------------------------------------------------
/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
16 |
17 | * Control the data you get on client side.
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 | ## Writing & Deploying Docs
154 | Run `pip3 install mkdocs-material` to install mkdocs-material
155 |
156 | Run `mkdocs serve` to serve docs locally
157 |
158 | Run `mkdocs gh-deploy --force` to deploy docs to gh-page
159 |
160 |
161 | ## Credits
162 | * Implementation of this library is based on the idea behind [GraphQL](https://graphql.org/).
163 | * 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.
164 |
165 |
166 | ## Contributing [](http://makeapullrequest.com)
167 |
168 | 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!.
169 |
--------------------------------------------------------------------------------
/tests/testapp/serializers.py:
--------------------------------------------------------------------------------
1 | from rest_framework import serializers
2 |
3 | from django_restql.mixins import DynamicFieldsMixin
4 | from django_restql.serializers import NestedModelSerializer
5 | from django_restql.fields import NestedField, DynamicSerializerMethodField
6 | from tests.testapp.models import (
7 | Book, Post, Genre, Phone, Course, Student, Attachment, Instructor
8 | )
9 |
10 |
11 | ######## Serializers for Data Querying And Mutations Testing ##########
12 | class GenreSerializer(DynamicFieldsMixin, serializers.ModelSerializer):
13 | class Meta:
14 | model = Genre
15 | fields = ["title", "description"]
16 |
17 |
18 | class InstructorSerializer(DynamicFieldsMixin, serializers.ModelSerializer):
19 | class Meta:
20 | model = Instructor
21 | fields = ["name"]
22 |
23 |
24 | class BookSerializer(DynamicFieldsMixin, serializers.ModelSerializer):
25 | class Meta:
26 | model = Book
27 | fields = ["title", "author"]
28 |
29 |
30 | class PhoneSerializer(DynamicFieldsMixin, serializers.ModelSerializer):
31 | class Meta:
32 | model = Phone
33 | fields = ["number", "type", "student"]
34 |
35 |
36 | ################# Serializers for Data Querying Testing ################
37 | class CourseSerializer(DynamicFieldsMixin, serializers.ModelSerializer):
38 | books = BookSerializer(many=True, read_only=True)
39 |
40 | class Meta:
41 | model = Course
42 | fields = ["name", "code", "books"]
43 |
44 |
45 | class CourseWithDisableDynamicFieldsKwargSerializer(
46 | DynamicFieldsMixin, serializers.ModelSerializer
47 | ):
48 | books = BookSerializer(many=True, read_only=True, disable_dynamic_fields=True)
49 |
50 | class Meta:
51 | model = Course
52 | fields = ["name", "code", "books"]
53 |
54 |
55 | class CourseWithReturnPkkwargSerializer(CourseSerializer):
56 | books = BookSerializer(many=True, read_only=True, return_pk=True)
57 |
58 | class Meta:
59 | model = Course
60 | fields = ["name", "code", "books"]
61 |
62 |
63 | class CourseWithFieldsKwargSerializer(CourseSerializer):
64 | books = BookSerializer(many=True, read_only=True, fields=["title"])
65 |
66 | class Meta(CourseSerializer.Meta):
67 | pass
68 |
69 |
70 | class CourseWithExcludeKwargSerializer(CourseSerializer):
71 | books = BookSerializer(many=True, read_only=True, exclude=["author"])
72 |
73 | class Meta(CourseSerializer.Meta):
74 | pass
75 |
76 |
77 | class CourseWithAliasedBooksSerializer(CourseSerializer):
78 | tomes = BookSerializer(source="books", many=True, read_only=True)
79 |
80 | class Meta:
81 | model = Course
82 | fields = ["name", "code", "tomes"]
83 |
84 |
85 | class CourseWithDynamicSerializerMethodField(CourseSerializer):
86 | tomes = DynamicSerializerMethodField()
87 | related_books = DynamicSerializerMethodField()
88 |
89 | class Meta:
90 | model = Course
91 | fields = ["name", "code", "tomes", "related_books"]
92 |
93 | def get_tomes(self, obj, parsed_query):
94 | books = obj.books.all()
95 | serializer = BookSerializer(
96 | books, parsed_query=parsed_query, many=True, read_only=True
97 | )
98 | return serializer.data
99 |
100 | def get_related_books(self, obj, parsed_query):
101 | books = obj.books.all()
102 | query = "{title}"
103 | serializer = BookSerializer(books, query=query, many=True, read_only=True)
104 | return serializer.data
105 |
106 |
107 | class StudentSerializer(DynamicFieldsMixin, serializers.ModelSerializer):
108 | course = CourseSerializer(many=False, read_only=True)
109 | phone_numbers = PhoneSerializer(many=True, read_only=True)
110 |
111 | class Meta:
112 | model = Student
113 | fields = ["name", "age", "course", "phone_numbers"]
114 |
115 |
116 | class StudentWithAliasSerializer(DynamicFieldsMixin, serializers.ModelSerializer):
117 | program = CourseSerializer(source="course", many=False, read_only=True)
118 | phone_numbers = PhoneSerializer(many=True, read_only=True)
119 |
120 | class Meta:
121 | model = Student
122 | fields = ["name", "age", "program", "phone_numbers"]
123 |
124 |
125 | ############### Serializers for Nested Data Mutation Testing ##############
126 | class WritableBookSerializer(DynamicFieldsMixin, NestedModelSerializer):
127 | genres = NestedField(GenreSerializer, many=True, required=False, partial=False)
128 |
129 | class Meta:
130 | model = Book
131 | fields = ["title", "author", "genres"]
132 |
133 |
134 | class WritableCourseSerializer(DynamicFieldsMixin, NestedModelSerializer):
135 | books = NestedField(
136 | WritableBookSerializer, many=True, required=False,
137 | allow_remove_all=True, allow_delete_all=True
138 | )
139 | instructor = NestedField(InstructorSerializer, accept_pk=True, required=False)
140 |
141 | class Meta:
142 | model = Course
143 | fields = ["name", "code", "books", "instructor"]
144 |
145 |
146 | class ReplaceableStudentSerializer(DynamicFieldsMixin, NestedModelSerializer):
147 | course = NestedField(
148 | WritableCourseSerializer, accept_pk=True, allow_null=True, required=False
149 | )
150 | phone_numbers = PhoneSerializer(many=True, read_only=True)
151 |
152 | class Meta:
153 | model = Student
154 | fields = ["name", "age", "course", "phone_numbers"]
155 |
156 |
157 | class ReplaceableStudentWithAliasSerializer(DynamicFieldsMixin, NestedModelSerializer):
158 | full_name = serializers.CharField(source="name")
159 | program = NestedField(
160 | WritableCourseSerializer,
161 | source="course",
162 | accept_pk_only=True,
163 | allow_null=True,
164 | required=False,
165 | )
166 | contacts = NestedField(
167 | PhoneSerializer, source="phone_numbers", many=True, required=False
168 | )
169 |
170 | class Meta:
171 | model = Student
172 | fields = ["full_name", "age", "program", "contacts"]
173 |
174 |
175 | class WritableStudentSerializer(DynamicFieldsMixin, NestedModelSerializer):
176 | course = NestedField(
177 | WritableCourseSerializer,
178 | allow_null=True, required=False, delete_on_null=True
179 | )
180 | phone_numbers = NestedField(
181 | PhoneSerializer, many=True, required=False,
182 | allow_remove_all=True, allow_delete_all=True
183 | )
184 |
185 | class Meta:
186 | model = Student
187 | fields = ["name", "age", "course", "phone_numbers"]
188 |
189 |
190 | class WritableStudentWithAliasSerializer(DynamicFieldsMixin, NestedModelSerializer):
191 | program = NestedField(
192 | WritableCourseSerializer, source="course", allow_null=True, required=False
193 | )
194 | contacts = NestedField(
195 | PhoneSerializer, source="phone_numbers", many=True, required=False
196 | )
197 | study_partner = NestedField(
198 | "self",
199 | required=False,
200 | allow_null=True,
201 | accept_pk=True,
202 | exclude=["study_partner"],
203 | )
204 | sport_mates = NestedField(
205 | "self",
206 | required=False,
207 | many=True,
208 | source="sport_partners",
209 | exclude=["sport_mates"],
210 | )
211 |
212 | class Meta:
213 | model = Student
214 | fields = ["name", "age", "program", "contacts", "study_partner", "sport_mates"]
215 |
216 |
217 | class AttachmentSerializer(serializers.ModelSerializer):
218 | class Meta:
219 | model = Attachment
220 | fields = ["content"]
221 |
222 |
223 | class PostSerializer(NestedModelSerializer):
224 | attachments = NestedField(AttachmentSerializer, many=True, required=False)
225 |
226 | class Meta:
227 | model = Post
228 | fields = ["title", "content", "attachments"]
229 |
--------------------------------------------------------------------------------
/django_restql/fields.py:
--------------------------------------------------------------------------------
1 | try:
2 | from django.utils.decorators import classproperty
3 | except ImportError:
4 | from django.utils.functional import classproperty
5 |
6 | from django.db.models.fields.related import ManyToOneRel
7 | from rest_framework.fields import Field, DictField, ListField, SkipField, empty
8 | from rest_framework.serializers import (
9 | ListSerializer, ValidationError, SerializerMethodField, PrimaryKeyRelatedField
10 | )
11 |
12 | from .parser import Query
13 | from .exceptions import InvalidOperation
14 | from .operations import ADD, CREATE, DELETE, REMOVE, UPDATE
15 |
16 |
17 | CREATE_OPERATIONS = (ADD, CREATE)
18 | UPDATE_OPERATIONS = (ADD, CREATE, UPDATE, REMOVE, DELETE)
19 |
20 | ALL_RELATED_OBJS = "__all__"
21 |
22 |
23 | class DynamicSerializerMethodField(SerializerMethodField):
24 | def to_representation(self, value):
25 | method = getattr(self.parent, self.method_name)
26 | is_parsed_query_available = (
27 | hasattr(self.parent, "restql_nested_parsed_queries") and
28 | self.field_name in self.parent.restql_nested_parsed_queries
29 | )
30 |
31 | if is_parsed_query_available:
32 | parsed_query = self.parent.restql_nested_parsed_queries[self.field_name]
33 | else:
34 | # Include all fields
35 | parsed_query = Query(
36 | field_name=None,
37 | included_fields=["*"],
38 | excluded_fields=[],
39 | aliases={},
40 | arguments={}
41 | )
42 | return method(value, parsed_query)
43 |
44 |
45 | class BaseRESTQLNestedField(object):
46 | def to_internal_value(self, data):
47 | raise NotImplementedError("`to_internal_value()` must be implemented.")
48 |
49 |
50 | def BaseNestedFieldSerializerFactory(
51 | *args,
52 | accept_pk=False,
53 | accept_pk_only=False,
54 | delete_on_null=False,
55 | serializer_class=None,
56 | allow_remove_all=False,
57 | allow_delete_all=False,
58 | create_ops=CREATE_OPERATIONS,
59 | update_ops=UPDATE_OPERATIONS,
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 | assert not (
84 | allow_delete_all and not many
85 | ), (
86 | "`allow_delete_all=True` can only be applied to many related "
87 | "nested fields, ensure the kwarg `many=True` is set."
88 | )
89 |
90 | assert not (
91 | delete_on_null and accept_pk
92 | ), "`delete_on_null=True` can not be used if `accept_pk=True`."
93 |
94 | assert not (
95 | delete_on_null and accept_pk_only
96 | ), "`delete_on_null=True` can not be used if `accept_pk_only=True`."
97 |
98 | def join_words(words, many="are", single="is"):
99 | word_list = ["`" + word + "`" for word in words]
100 |
101 | if len(words) > 1:
102 | sentence = " & ".join([", ".join(word_list[:-1]), word_list[-1]])
103 | return "%s %s" % (many, sentence)
104 | elif len(words) == 1:
105 | return "%s %s" % (single, word_list[0])
106 | return "%s %s" % (single, "[]")
107 |
108 | if not set(create_ops).issubset(set(CREATE_OPERATIONS)):
109 | msg = (
110 | "Invalid create operation(s) at `%s`, Supported operations " +
111 | join_words(CREATE_OPERATIONS)
112 | ) % "create_ops=%s" % create_ops
113 | raise InvalidOperation(msg)
114 |
115 | if not set(update_ops).issubset(set(UPDATE_OPERATIONS)):
116 | msg = (
117 | "Invalid update operation(s) at `%s`, Supported operations " +
118 | join_words(UPDATE_OPERATIONS)
119 | ) % "update_ops=%s" % update_ops
120 | raise InvalidOperation(msg)
121 |
122 | if serializer_class == "self":
123 | # We have a self referencing serializer so the serializer
124 | # class is not available at the moment, we return None
125 | return None
126 |
127 | class BaseNestedField(BaseRESTQLNestedField):
128 | @classproperty
129 | def serializer_class(cls):
130 | # Return original nested serializer
131 | return serializer_class
132 |
133 | def is_partial(self, default):
134 | # Check if partial kwarg is passed if not return the default
135 | if partial is not None:
136 | return partial
137 | return default
138 |
139 | class BaseNestedFieldListSerializer(ListSerializer, BaseNestedField):
140 | def run_pk_list_validation(self, pks):
141 | ListField().run_validation(pks)
142 | queryset = self.child.Meta.model.objects.all()
143 | PrimaryKeyRelatedField(
144 | **self.child.validation_kwargs,
145 | queryset=queryset,
146 | many=True
147 | ).run_validation(pks)
148 |
149 | def run_data_list_validation(self, data, partial=None, operation=None):
150 | ListField().run_validation(data)
151 | model = self.parent.Meta.model
152 | rel = getattr(model, self.source).rel
153 | if isinstance(rel, ManyToOneRel):
154 | # ManyToOne Relation
155 | field_name = getattr(model, self.source).field.name
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 | # Remove parent field(field_name) for validation purpose
165 | child_serializer.child.fields.pop(field_name, None)
166 |
167 | # Check if a serializer is valid
168 | child_serializer.is_valid(raise_exception=True)
169 | else:
170 | # ManyToMany Relation
171 | child_serializer = serializer_class(
172 | **self.child.validation_kwargs,
173 | data=data,
174 | many=True,
175 | partial=partial,
176 | context={**self.context, "parent_operation": operation}
177 | )
178 |
179 | # Check if a serializer is valid
180 | child_serializer.is_valid(raise_exception=True)
181 |
182 | def run_add_list_validation(self, data):
183 | self.run_pk_list_validation(data)
184 |
185 | def run_create_list_validation(self, data):
186 | self.run_data_list_validation(
187 | data,
188 | partial=self.is_partial(False),
189 | operation=CREATE
190 | )
191 |
192 | def run_remove_list_validation(self, data):
193 | if data == ALL_RELATED_OBJS:
194 | if not allow_remove_all:
195 | msg = (
196 | "Using `%s` value on `%s` operation is disabled"
197 | % (ALL_RELATED_OBJS, REMOVE)
198 | )
199 | raise ValidationError(msg, code="not_allowed")
200 | else:
201 | self.run_pk_list_validation(data)
202 |
203 | def run_delete_list_validation(self, data):
204 | if data == ALL_RELATED_OBJS:
205 | if not allow_delete_all:
206 | msg = (
207 | "Using `%s` value on `%s` operation is disabled"
208 | % (ALL_RELATED_OBJS, DELETE)
209 | )
210 | raise ValidationError(msg, code="not_allowed")
211 | else:
212 | self.run_pk_list_validation(data)
213 |
214 | def run_update_list_validation(self, data):
215 | DictField().run_validation(data)
216 | pks = list(data.keys())
217 | self.run_pk_list_validation(pks)
218 | values = list(data.values())
219 | self.run_data_list_validation(
220 | values,
221 | partial=self.is_partial(True),
222 | operation=UPDATE
223 | )
224 |
225 | def run_data_validation(self, data, allowed_ops):
226 | DictField().run_validation(data)
227 |
228 | operation_2_validation_method = {
229 | ADD: self.run_add_list_validation,
230 | CREATE: self.run_create_list_validation,
231 | UPDATE: self.run_update_list_validation,
232 | REMOVE: self.run_remove_list_validation,
233 | DELETE: self.run_delete_list_validation,
234 | }
235 |
236 | allowed_operation_2_validation_method = {
237 | operation: operation_2_validation_method[operation]
238 | for operation in allowed_ops
239 | }
240 |
241 | for operation, values in data.items():
242 | try:
243 | allowed_operation_2_validation_method[operation](values)
244 | except ValidationError as e:
245 | detail = {operation: e.detail}
246 | code = e.get_codes()
247 | raise ValidationError(detail, code=code) from None
248 | except KeyError:
249 | msg = (
250 | "`%s` is not a valid operation, valid operations(s) "
251 | "for this request %s"
252 | % (operation, join_words(allowed_ops))
253 | )
254 | code = "invalid_operation"
255 | raise ValidationError(msg, code=code) from None
256 |
257 | def to_internal_value(self, data):
258 | if self.child.root.instance is None:
259 | parent_operation = self.context.get("parent_operation")
260 | if parent_operation == UPDATE:
261 | # Definitely an update
262 | self.run_data_validation(data, update_ops)
263 | else:
264 | self.run_data_validation(data, create_ops)
265 | else:
266 | # Definitely an update
267 | self.run_data_validation(data, update_ops)
268 | return data
269 |
270 | def __repr__(self):
271 | return (
272 | "BaseNestedField(%s, many=True)" %
273 | (serializer_class.__name__, )
274 | )
275 |
276 | class BaseNestedFieldSerializer(serializer_class, BaseNestedField):
277 |
278 | # These variables might be used before `to_internal_value` method is called
279 | # so we're creating them to make sure they're available
280 | # as long as the class is created
281 | is_replaceable = accept_pk_only or accept_pk
282 | should_delete_on_null = delete_on_null
283 |
284 | class Meta(serializer_class.Meta):
285 | list_serializer_class = BaseNestedFieldListSerializer
286 |
287 | def run_validation(self, data):
288 | # Run `to_internal_value` only nothing more
289 | # This is needed only on DRF 3.8.x due to a bug on it
290 | # This function can be removed on other supported DRF versions
291 | # i.e v3.7 v3.9 v3.10 etc doesn't need this function
292 | return self.to_internal_value(data)
293 |
294 | def run_pk_validation(self, pk):
295 | queryset = self.Meta.model.objects.all()
296 | validator = PrimaryKeyRelatedField(
297 | **self.validation_kwargs,
298 | queryset=queryset,
299 | many=False
300 | )
301 | # If valid return object instead of pk
302 | return validator.run_validation(pk)
303 |
304 | def run_data_validation(self, data):
305 | parent_operation = self.context.get("parent_operation")
306 |
307 | child_serializer = serializer_class(
308 | **self.validation_kwargs,
309 | data=data,
310 | partial=self.is_partial(
311 | # Use the partial value passed, if it's not passed
312 | # Use the one from the top level parent
313 | True if parent_operation == UPDATE else False
314 | ),
315 | context=self.context
316 | )
317 |
318 | # Set parent to a child serializer
319 | child_serializer.parent = self.parent
320 |
321 | # Check if a serializer is valid
322 | child_serializer.is_valid(raise_exception=True)
323 |
324 | # return data to be passed to a nested serializer,
325 | # don't be tempted to return child_serializer.validated_data
326 | # cuz it changes representation of some values for instance
327 | # pks gets converted into objects
328 | return data
329 |
330 | def to_internal_value(self, data):
331 | required = kwargs.get("required", True)
332 | default = kwargs.get("default", empty)
333 |
334 | if data == empty:
335 | # Implementation under this block is made
336 | # according to DRF behaviour to other normal fields
337 | # For more details see
338 | # https://www.django-rest-framework.org/api-guide/fields/#required
339 | # https://www.django-rest-framework.org/api-guide/fields/#default
340 | # https://www.django-rest-framework.org/api-guide/fields/#allow_null
341 | if self.root.partial or not required:
342 | # Skip the field because the update is partial
343 | # or the field is not required(optional)
344 | raise SkipField()
345 | elif required:
346 | if default == empty:
347 | raise ValidationError(
348 | "This field is required.",
349 | code="required"
350 | )
351 | else:
352 | # Use the default value
353 | data = default
354 |
355 | if accept_pk_only:
356 | return self.run_pk_validation(data)
357 | elif accept_pk:
358 | if isinstance(data, dict):
359 | self.is_replaceable = False
360 | return self.run_data_validation(data)
361 | else:
362 | return self.run_pk_validation(data)
363 | return self.run_data_validation(data)
364 |
365 | def __repr__(self):
366 | return (
367 | "BaseNestedField(%s, many=False)" %
368 | (serializer_class.__name__, )
369 | )
370 |
371 | return {
372 | "serializer_class": BaseNestedFieldSerializer,
373 | "list_serializer_class": BaseNestedFieldListSerializer,
374 | "args": args,
375 | "kwargs": kwargs
376 | }
377 |
378 |
379 | class TemporaryNestedField(Field, BaseRESTQLNestedField):
380 | """
381 | This is meant to be used temporarily when "self" is
382 | passed as the first arg to `NestedField`
383 | """
384 |
385 | def __init__(
386 | self, NestedField, *args,
387 | field_args=None, field_kwargs=None, **kwargs):
388 | self.field_args = field_args
389 | self.field_kwargs = field_kwargs
390 | self.NestedField = NestedField
391 | super().__init__(*args, **kwargs)
392 |
393 | def get_actual_nested_field(self, serializer_class):
394 | # Replace "self" with the actual parent serializer class
395 | self.field_kwargs.update({
396 | "serializer_class": serializer_class
397 | })
398 |
399 | # Reproduce the actual field
400 | return self.NestedField(
401 | *self.field_args,
402 | **self.field_kwargs
403 | )
404 |
405 |
406 | def NestedFieldWraper(*args, **kwargs):
407 | serializer_class = kwargs["serializer_class"]
408 | factory = BaseNestedFieldSerializerFactory(*args, **kwargs)
409 |
410 | if factory is None:
411 | # We have a self referencing serializer so we return
412 | # a temporary field while we are waiting for the parent
413 | # to be ready(when it's ready the parent itself will replace
414 | # this field with the actual field)
415 | return TemporaryNestedField(
416 | NestedFieldWraper,
417 | field_args=args,
418 | field_kwargs=kwargs
419 | )
420 |
421 | serializer_validation_kwargs = {**factory["kwargs"]}
422 |
423 | # Remove all non validation related kwargs and
424 | # DynamicFieldsMixin kwargs from `valdation_kwargs`
425 | non_validation_related_kwargs = [
426 | "many", "data", "instance", "context", "fields",
427 | "exclude", "return_pk", "disable_dynamic_fields",
428 | "query", "parsed_query", "partial"
429 | ]
430 |
431 | for kwarg in non_validation_related_kwargs:
432 | serializer_validation_kwargs.pop(kwarg, None)
433 |
434 | class NestedListSerializer(factory["list_serializer_class"]):
435 | def __repr__(self):
436 | return (
437 | "NestedField(%s, many=False)" %
438 | (serializer_class.__name__, )
439 | )
440 |
441 | class NestedSerializer(factory["serializer_class"]):
442 | # set validation related kwargs to be used on
443 | # `NestedCreateMixin` and `NestedUpdateMixin`
444 | validation_kwargs = serializer_validation_kwargs
445 |
446 | class Meta(factory["serializer_class"].Meta):
447 | list_serializer_class = NestedListSerializer
448 |
449 | def __repr__(self):
450 | return (
451 | "NestedField(%s, many=False)" %
452 | (serializer_class.__name__, )
453 | )
454 |
455 | return NestedSerializer(
456 | *factory["args"],
457 | **factory["kwargs"]
458 | )
459 |
460 |
461 | def NestedField(serializer_class, *args, **kwargs):
462 | return NestedFieldWraper(
463 | *args,
464 | **kwargs,
465 | serializer_class=serializer_class
466 | )
467 |
--------------------------------------------------------------------------------
/docs/mutating_data.md:
--------------------------------------------------------------------------------
1 | # Mutating Data
2 | **Django RESTQL** got your back on creating and updating nested data too, it supports creating and updating nested data through two main components:
3 |
4 | - `NestedModelSerializer` – handles the `create` and `update` logic for nested fields.
5 |
6 | - `NestedField` – validates nested data before passing it to `create` or `update`.
7 |
8 | ## Using NestedField and NestedModelSerializer
9 | Just like in querying data, mutating nested data with **Django RESTQL** is straightforward:
10 |
11 | 1. Inherit `NestedModelSerializer` in a serializer with nested fields.
12 | 2. Use `NestedField` to define any nested field you want to be able to mutate.
13 |
14 | Example:
15 |
16 | ```py
17 | from rest_framework import serializers
18 | from django_restql.serializers import NestedModelSerializer
19 | from django_restql.fields import NestedField
20 |
21 | from app.models import Location, Amenity, Property
22 |
23 |
24 | class LocationSerializer(serializers.ModelSerializer):
25 | class Meta:
26 | model = Location
27 | fields = ["id", "city", "country"]
28 |
29 |
30 | class AmenitySerializer(serializers.ModelSerializer):
31 | class Meta:
32 | model = Amenity
33 | fields = ["id", "name"]
34 |
35 |
36 | # Inherit NestedModelSerializer to support create and update
37 | # on nested fields
38 | class PropertySerializer(NestedModelSerializer):
39 | # Define location as nested field
40 | location = NestedField(LocationSerializer)
41 |
42 | # Define amenities as nested field
43 | amenities = NestedField(
44 | AmenitySerializer, many=True
45 | create_ops=["add", "create"], # Allow only "add" and "create" operations
46 | update_ops=["add", "create", "update", "remove", "delete"] # Allow all operations
47 | )
48 | class Meta:
49 | model = Property
50 | fields = [
51 | "id", "price", "location", "amenities"
52 | ]
53 | ```
54 |
55 | Example – Creating Data
56 |
57 | ```POST /api/property/```
58 |
59 | Request body
60 | ```js
61 | {
62 | "price": 60000,
63 | "location": {
64 | "city": "Newyork",
65 | "country": "USA"
66 | },
67 | "amenities": {
68 | "add": [3],
69 | "create": [
70 | {"name": "Watererr"},
71 | {"name": "Electricity"}
72 | ]
73 | }
74 | }
75 | ```
76 |
77 | Response
78 | ```js
79 | {
80 | "id": 2,
81 | "price": 60000,
82 | "location": {
83 | "id": 3,
84 | "city": "Newyork",
85 | "country": "USA"
86 | },
87 | "amenities": [
88 | {"id": 1, "name": "Watererr"},
89 | {"id": 2, "name": "Electricity"},
90 | {"id": 3, "name": "Swimming Pool"}
91 | ]
92 | }
93 | ```
94 |
95 | Just to clarify what happed here:
96 |
97 | - A new location was created and linked to the property.
98 | - `create` operation added new amenities and linked them to the property.
99 | - `add` operaton linked an existing amenity (id=3) to the property.
100 |
101 | !!! note
102 | For `POST` with many-related fields, only `create` and `add` operations are supported.
103 |
104 | Below we have an example where we are trying to update the property we have created in the previous example.
105 |
106 | ```PUT/PATCH /api/property/2/```
107 |
108 | Request Body
109 | ```js
110 | {
111 | "price": 50000,
112 | "location": {
113 | "city": "Newyork",
114 | "country": "USA"
115 | },
116 | "amenities": {
117 | "add": [4],
118 | "create": [{"name": "Fance"}],
119 | "update": {1: {"name": "Water"}},
120 | "remove": [3],
121 | "delete": [2]
122 | }
123 | }
124 | ```
125 |
126 | Response
127 |
128 | ```js
129 | {
130 | "id": 2,
131 | "price": 50000,
132 | "location": {
133 | "id": 3,
134 | "city": "Newyork",
135 | "country": "USA"
136 | },
137 | "amenities": [
138 | {"id": 1, "name": "Water"},
139 | {"id": 4, "name": "Bathtub"},
140 | {"id": 5, "name": "Fance"}
141 | ]
142 | }
143 | ```
144 |
145 | Here is what really happened after sending the update request
146 |
147 | - `add` operation linked an existing amenity (id=4).
148 |
149 | - `create` operation added a new amenity.
150 |
151 | - `update` operation modified the amenity with id=1.
152 |
153 | - `remove` operation unlinked amenity with id=3.
154 |
155 | - `delete` operation unlinked amenity with id=2 and deleted it from the DB .
156 |
157 | !!! note
158 | For PUT/PATCH with many-related fields, the supported operations are: `add`, `create`, `update`, `remove` and `delete`.
159 |
160 |
161 | ## Operations table for many-related fields
162 |
163 | Operation | Supported In | Description |
164 | ----------|----------------|----------------------------------------------|
165 | add |POST, PUT/PATCH | Adds existing related items by ID |
166 | create |POST, PUT/PATCH | Creates new related items from provided data |
167 | update |PUT/PATCH | Updates existing related items by ID |
168 | remove |PUT/PATCH | Removes related items (keeps them in DB) |
169 | delete |PUT/PATCH | Deletes related items from the DB |
170 |
171 |
172 | ## Self-referencing nested fields
173 | By default, **Django REST Framework (DRF)** does not allow you to directly declare self-referencing nested fields in serializers. However, Django itself supports self-referential relationships, and your models may include them.
174 |
175 | **Django RESTQL** provides a clean way to handle this scenario without running into recursion issues.
176 |
177 | Example:
178 |
179 | ```py
180 | # models.py
181 |
182 | class Student(models.Model):
183 | name = models.CharField(max_length=50)
184 | age = models.IntegerField()
185 | study_partners = models.ManyToManyField("self", related_name="study_partners")
186 | ```
187 |
188 | In this model, `study_partners` is a **self-referencing ManyToMany field** — it points to the same model(`Student`).
189 |
190 | ```py
191 | # serializers.py
192 |
193 | class StudentSerializer(NestedModelSerializer):
194 | # Define study_partners as a self-referencing nested field
195 | study_partners = NestedField(
196 | "self", # References the same serializer
197 | many=True,
198 | required=False,
199 | exclude=["study_partners"] # Prevent infinite recursion
200 | )
201 |
202 | class Meta:
203 | model = Student
204 | fields = ["id", "name", "age", "study_partners"]
205 | ```
206 |
207 | Key Points:
208 |
209 | - Passing `"self"` to `NestedField` tells **Django RESTQL** that this nested field should use the same serializer it’s declared in.
210 |
211 | - Self-referencing relationships can be cyclic, leading to infinite nesting.
212 | Using `exclude=["study_partners"]` prevents the nested serializer from including `study_partners` again inside itself.
213 |
214 |
215 | ## NestedField kwargs
216 |
217 | `NestedField` supports additional keyword arguments (kwargs) beyond those accepted by a serializer. These kwargs allows extra customizations when working with nested fields.
218 |
219 | ### accept_pk kwarg
220 | **Default:** `False`
221 |
222 | **Applies to:** ForeignKey relations only
223 |
224 | When set to `True`, `accept_pk` lets you update a nested field using the **primary key (pk/id)** of an existing resource instead of sending the full nested object.
225 | This is useful when you want to associate an existing resource with the parent resource without re-sending all its data.
226 |
227 | Below is an example showing how to use `accept_pk` kwarg.
228 |
229 | ```py
230 | from rest_framework import serializers
231 | from django_restql.fields import NestedField
232 | from django_restql.serializers import NestedModelSerializer
233 |
234 | from app.models import Location, Property
235 |
236 |
237 | class LocationSerializer(serializers.ModelSerializer):
238 | class Meta:
239 | model = Location
240 | fields = ["id", "city", "country"]
241 |
242 |
243 | class PropertySerializer(NestedModelSerializer):
244 | # pk based nested field
245 | location = NestedField(LocationSerializer, accept_pk=True)
246 | class Meta:
247 | model = Property
248 | fields = [
249 | "id", "price", "location"
250 | ]
251 | ```
252 |
253 | Now sending create request as
254 |
255 |
256 | ```POST /api/property/```
257 |
258 | Request Body
259 | ```js
260 | {
261 | "price": 40000,
262 | "location": 2
263 | }
264 | ```
265 | !!! note
266 | 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.
267 |
268 | Response
269 | ```js
270 | {
271 | "id": 1,
272 | "price": 40000,
273 | "location": {
274 | "id": 2,
275 | "city": "Tokyo",
276 | "country": "China"
277 | }
278 | }
279 | ```
280 |
281 | Using `accept_pk=True` doesn't limit you from sending full data to nested field, setting `accept_pk=True` means you can send both full data and pks. For instance from the above example you could still do
282 |
283 | ```POST /api/property/```
284 |
285 | Request Body
286 | ```js
287 | {
288 | "price": 63000,
289 | "location": {
290 | "city": "Dodoma",
291 | "country": "Tanzania"
292 | }
293 | }
294 | ```
295 |
296 | Response
297 | ```js
298 | {
299 | "id": 2,
300 | "price": 63000,
301 | "location": {
302 | "id": 3,
303 | "city": "Dodoma",
304 | "country": "Tanzania"
305 | }
306 | }
307 | ```
308 |
309 |
310 | ### accept_pk_only kwarg
311 | **Default:** `False`
312 |
313 | **Applies to:** ForeignKey relations only
314 |
315 | `accept_pk_only=True` is used if you want to be able to update nested field by using pk/id only. If `accept_pk_only=True` is set you won't be able to send data to create a nested resource.
316 |
317 | Below is an example showing how to use `accept_pk_only` kwarg.
318 | ```py
319 | from rest_framework import serializers
320 | from django_restql.fields import NestedField
321 | from django_restql.serializers import NestedModelSerializer
322 |
323 | from app.models import Location, Property
324 |
325 |
326 | class LocationSerializer(serializers.ModelSerializer):
327 | class Meta:
328 | model = Location
329 | fields = ["id", "city", "country"]
330 |
331 |
332 | class PropertySerializer(NestedModelSerializer):
333 | # pk based nested field
334 | location = NestedField(LocationSerializer, accept_pk_only=True)
335 | class Meta:
336 | model = Property
337 | fields = [
338 | "id", "price", "location"
339 | ]
340 | ```
341 |
342 | Sending mutation request
343 |
344 | ```POST /api/property/```
345 |
346 | Request Body
347 | ```js
348 | {
349 | "price": 40000,
350 | "location": 2 // You can't send data in here, you can only send pk/id
351 | }
352 | ```
353 |
354 | Response
355 | ```js
356 | {
357 | "id": 1,
358 | "price": 40000,
359 | "location": {
360 | "id": 2,
361 | "city": "Tokyo",
362 | "country": "China"
363 | }
364 | }
365 | ```
366 |
367 | !!! note
368 | By default `accept_pk=False` and `accept_pk_only=False`, so all nested fields(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`.
369 |
370 |
371 | ### create_ops and update_ops kwargs.
372 | The `create_ops` and `update_ops` keyword arguments allow you to restrict certain operations when creating or updating nested data on a many-related field.
373 | This helps to enforce rules on what clients are allowed to do during mutations.
374 |
375 | Default Value:
376 |
377 | - `create_ops=["add", "create"]`
378 | - `update_ops=["add", "create", "update", "remove", "delete"]`
379 |
380 | !!! note
381 | It's important to explicitly specify the operations that you want your many-related nested field to support, if you don't, default values will be used.
382 |
383 | Example:
384 |
385 | ```py
386 | from rest_framework import serializers
387 | from django_restql.fields import NestedField
388 | from django_restql.serializers import NestedModelSerializer
389 |
390 | from app.models import Location, Amenity, Property
391 |
392 |
393 | class AmenitySerializer(serializers.ModelSerializer):
394 | class Meta:
395 | model = Amenity
396 | fields = ["id", "name"]
397 |
398 |
399 | class PropertySerializer(NestedModelSerializer):
400 | amenities = NestedField(
401 | AmenitySerializer,
402 | many=True,
403 | create_ops=["add"], # Allow only "add" operation when creating
404 | update_ops=["add", "remove"] # Allow only "add" and "remove" operations when updating
405 | )
406 | class Meta:
407 | model = Property
408 | fields = [
409 | "id", "price", "amenities"
410 | ]
411 | ```
412 |
413 | Sending create mutation request
414 |
415 | ```POST /api/property/```
416 |
417 | Request Body
418 | ```js
419 | {
420 | "price": 60000,
421 | "amenities": {
422 | "add": [1, 2]
423 | }
424 | }
425 | ```
426 | !!! note
427 | Since `create_ops=["add"]`, you can not use `create` operation in here!.
428 |
429 | Response
430 | ```js
431 | {
432 | "id": 2,
433 | "price": 60000,
434 | "amenities": [
435 | {"id": 1, "name": "Watererr"},
436 | {"id": 2, "name": "Electricity"}
437 | ]
438 | }
439 | ```
440 |
441 | Sending update mutation request
442 |
443 | ```PUT/PATCH /api/property/2/```
444 |
445 | Request Body
446 | ```js
447 | {
448 | "price": 50000,
449 | "amenities": {
450 | "add": [3],
451 | "remove": [2]
452 | }
453 | }
454 | ```
455 | !!! note
456 | Since `update_ops=["add", "remove"]`, you can not use `create`, `update` or `delete` operation in here!.
457 |
458 | Response
459 | ```js
460 | {
461 | "id": 2,
462 | "price": 50000,
463 | "amenities": [
464 | {"id": 1, "name": "Water"},
465 | {"id": 3, "name": "Bathtub"}
466 | ]
467 | }
468 | ```
469 |
470 |
471 | ### allow_remove_all kwarg
472 | 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
473 |
474 | ```py
475 | class CourseSerializer(NestedModelSerializer):
476 | books = NestedField(BookSerializer, many=True, allow_remove_all=True)
477 |
478 | class Meta:
479 | model = Course
480 | fields = ["name", "code", "books"]
481 | ```
482 |
483 | With `allow_remove_all=True` as set above you will be able to send a request like
484 |
485 | ```PUT/PATCH /courses/3/```
486 |
487 | Request Body
488 | ```js
489 | {
490 | "books": {
491 | "remove": "__all__"
492 | }
493 | }
494 | ```
495 |
496 | This will remove all books associated with a course being updated.
497 |
498 |
499 | ### allow_delete_all kwarg
500 | This kwarg is used to enable and disable deleting all related objects on many related nested field at once by using `__all__` directive. The default value of `allow_delete_all` is `False`, which means deleting 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
501 |
502 | ```py
503 | class CourseSerializer(NestedModelSerializer):
504 | books = NestedField(BookSerializer, many=True, allow_delete_all=True)
505 |
506 | class Meta:
507 | model = Course
508 | fields = ["name", "code", "books"]
509 | ```
510 |
511 | With `allow_delete_all=True` as set above you will be able to send a request like
512 |
513 | ```PUT/PATCH /courses/3/```
514 |
515 | Request Body
516 | ```js
517 | {
518 | "books": {
519 | "delete": "__all__"
520 | }
521 | }
522 | ```
523 |
524 | This will delete all books associated with a course being updated.
525 |
526 |
527 | ### delete_on_null kwarg
528 | When dealing with nested fields, there are scenarios where the previously assigned object or resource is no longer needed after the field is cleared (i.e. set to `null`). In such cases, passing `delete_on_null=True` kwarg enables automatic deletion of the previously assigned resource when the nested field is explicitly updated to `null`.
529 |
530 | This keyword argument applies only to `ForeignKey` or `OneToOne` relationships.
531 | The default value for `delete_on_null` kwarg is `False`.
532 |
533 | Below is an example showing how to use `delete_on_null` kwarg.
534 | ```py
535 | from rest_framework import serializers
536 | from django_restql.fields import NestedField
537 | from django_restql.serializers import NestedModelSerializer
538 |
539 | from app.models import Location, Property
540 |
541 |
542 | class LocationSerializer(serializers.ModelSerializer):
543 | class Meta:
544 | model = Location
545 | fields = ["id", "city", "country"]
546 |
547 |
548 | class PropertySerializer(NestedModelSerializer):
549 | location = NestedField(LocationSerializer, delete_on_null=True)
550 | class Meta:
551 | model = Property
552 | fields = [
553 | "id", "price", "location"
554 | ]
555 | ```
556 |
557 | Assuming we have a property with this structure
558 | ```js
559 | {
560 | "id": 1,
561 | "price": 30000,
562 | "location": {
563 | "id": 5,
564 | "city": "Arusha",
565 | "country": "Tanzania"
566 | }
567 | }
568 | ```
569 |
570 | Sending a mutation request to update this property by removing a location
571 |
572 | ```PUT/PATCH /api/property/1/```
573 |
574 | Request Body
575 | ```js
576 | {
577 | "location": null
578 | }
579 | ```
580 |
581 | Response
582 | ```js
583 | {
584 | "id": 1,
585 | "price": 30000,
586 | "location": null
587 | }
588 | ```
589 |
590 | In this case, the property’s location is updated to `null`, and the previously assigned Location instance (with id: 5) is deleted from the database.
591 |
592 | !!! note
593 | `delete_on_null=True` can only be used when both `accept_pk=False` and `accept_pk_only=False`. This is because `accept_pk=True` or `accept_pk_only=True` typically implies that the nested object is not tightly coupled to the parent and may be referenced elsewhere. Automatically deleting it in such cases could lead to unintended side effects or broken references.
594 |
595 |
596 | ## Using DynamicFieldsMixin and NestedField together
597 | You can combine `DynamicFieldsMixin` with `NestedModelSerializer` to create serializers that are both writable on nested fields and support dynamic field querying, this is a very common pattern.
598 | Below is an example which shows how you can use `DynamicFieldsMixin` and `NestedField` together.
599 |
600 | ```py
601 | from rest_framework import serializers
602 | from django_restql.fields import NestedField
603 | from django_restql.mixins import DynamicFieldsMixin
604 | from django_restql.serializers import NestedModelSerializer
605 |
606 | from app.models import Location, Property
607 |
608 |
609 | class LocationSerializer(DynamicFieldsMixin, serializers.ModelSerializer):
610 | class Meta:
611 | model = Location
612 | fields = ["id", "city", "country"]
613 |
614 | # Inherit both DynamicFieldsMixin and NestedModelSerializer
615 | class PropertySerializer(DynamicFieldsMixin, NestedModelSerializer):
616 | location = NestedField(LocationSerializer)
617 | class Meta:
618 | model = Property
619 | fields = [
620 | "id", "price", "location"
621 | ]
622 | ```
623 |
624 | `NestedField` acts as a wrapper around a serializer. It instantiates a modified version of the passed serializer class, forwarding all arguments(args) and keyword arguments(kwargs) to it.
625 |
626 | Because of this, you can pass any argument accepted by the serializer to `NestedField`. For example, if the nested serializer inherits from `DynamicFieldsMixin` (like `LocationSerializer` above), you can use any of its supported kwargs such as:
627 |
628 | ```py
629 | location = NestedField(LocationSerializer, fields=[...])
630 | ```
631 |
632 | ```py
633 | location = NestedField(LocationSerializer, exclude=[...])
634 | ```
635 |
636 | ```py
637 | location = NestedField(LocationSerializer, return_pk=True)
638 | ```
639 |
640 |
641 | !!! note
642 | 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
643 |
644 | ```py
645 | from rest_framework import serializers
646 | from django_restql.fields import NestedField
647 | from django_restql.mixins import DynamicFieldsMixin
648 | from django_restql.serializers import NestedModelSerializer
649 |
650 | from app.models import Location, Property
651 |
652 |
653 | class LocationSerializer(serializers.ModelSerializer):
654 | class Meta:
655 | model = Location
656 | fields = ["id", "city", "country"]
657 |
658 |
659 | class PropertySerializer(NestedModelSerializer):
660 | # Passing both `required=False` and `allow_null=True`
661 | location = NestedField(LocationSerializer, required=False, allow_null=True)
662 | class Meta:
663 | model = Property
664 | fields = [
665 | "id", "price", "location"
666 | ]
667 | ```
668 |
669 | The `required=False` kwarg allows you to create a 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
670 |
671 | Sending mutation request
672 |
673 | ```POST /api/property/```
674 |
675 | Request Body
676 | ```js
677 | {
678 | "price": 40000
679 | // You can see that the location is not included here
680 | }
681 | ```
682 |
683 | Response
684 | ```js
685 | {
686 | "id": 2,
687 | "price": 50000,
688 | "location": null // This is the result of not including location
689 | }
690 | ```
691 |
692 | 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.
693 |
694 |
695 | ## Working with data mutation without request
696 | **Django RESTQL** supports data mutation independently of the HTTP request object. This is useful when you want to work with serializer data directly, without receiving it from an API request.
697 |
698 | Both `NestedModelSerializer` and `NestedField` can function standalone without relying on a request.
699 |
700 | Below is an example showing how you can work with data mutation without a request object.
701 |
702 | ```py
703 | from rest_framework import serializers
704 | from django_restql.fields import NestedField
705 | from django_restql.mixins import DynamicFieldsMixin
706 | from django_restql.serializers import NestedModelSerializer
707 |
708 | from app.models import Book, Course
709 |
710 |
711 | class BookSerializer(DynamicFieldsMixin, NestedModelSerializer):
712 | class Meta:
713 | model = Book
714 | fields = ["id", "title", "author"]
715 |
716 |
717 | class CourseSerializer(DynamicFieldsMixin, NestedModelSerializer):
718 | books = NestedField(BookSerializer, many=True, required=False)
719 | class Meta:
720 | model = Course
721 | fields = ["id", "name", "code", "books"]
722 | ```
723 |
724 | From serializers above you can create a course like
725 |
726 | ```py
727 | data = {
728 | "name": "Computer Programming",
729 | "code": "CS50",
730 | "books": {
731 | "add": [1, 2],
732 | "create": [
733 | {"title": "Basic Data Structures", "author": "J. Davis"},
734 | {"title": "Advanced Data Structures", "author": "S. Mobit"}
735 | ]
736 | }
737 | }
738 |
739 | serializer = CourseSerializer(data=data)
740 | serializer.is_valid()
741 | serializer.save()
742 |
743 | print(serializer.data)
744 |
745 | # This will print
746 | {
747 | "id": 2,
748 | "name": "Computer Programming",
749 | "code": "CS50",
750 | "books": [
751 | {"id": 1, "title": "Programming Intro", "author": "K. Moses"},
752 | {"id": 2, "title": "Understanding Computers", "author": "B. Gibson"},
753 | {"id": 3, "title": "Basic Data Structures", "author": "J. Davis"},
754 | {"id": 4, "title": "Advanced Data Structures", "author": "S. Mobit"}
755 | ]
756 | }
757 | ```
758 |
759 | To update a created course you can do it like
760 |
761 | ```py
762 | data = {
763 | "code": "CS100",
764 | "books": {
765 | "remove": [2, 3]
766 | }
767 | }
768 |
769 | course_obj = Course.objects.get(pk=2)
770 |
771 | serializer = CourseSerializer(course_obj, data=data)
772 | serializer.is_valid()
773 | serializer.save()
774 |
775 | print(serializer.data)
776 |
777 | # This will print
778 | {
779 | "id": 2,
780 | "name": "Computer Programming",
781 | "code": "CS100",
782 | "books": [
783 | {"id": 1, "title": "Programming Intro", "author": "K. Moses"},
784 | {"id": 2, "title": "Understanding Computers", "author": "B. Gibson"}
785 | ]
786 | }
787 | ```
--------------------------------------------------------------------------------
/docs/querying_data.md:
--------------------------------------------------------------------------------
1 | # Querying Data
2 | **Django RESTQL** simplifies querying data by allowing you to dynamically select fields to be included in a response.
3 |
4 | To enable this, simply inherit from the `DynamicFieldsMixin` class when defining your serializer, that's all.
5 |
6 | Below is an example showing how to use `DynamicFieldsMixin`.
7 |
8 | ```py
9 | from rest_framework import serializers
10 | from django.contrib.auth.models import User
11 | from django_restql.mixins import DynamicFieldsMixin
12 |
13 |
14 | class UserSerializer(DynamicFieldsMixin, serializer.ModelSerializer):
15 | class Meta:
16 | model = User
17 | fields = ["id", "username", "email"]
18 | ```
19 |
20 | Here a regular request returns all fields specified in the serializer, as **Django RESTQL** does not interfere with normal requests.
21 |
22 | Below is an example of a regular request and its response
23 |
24 | `GET /users`
25 |
26 | ```js
27 | [
28 | {
29 | "id": 1,
30 | "username": "yezyilomo",
31 | "email": "yezileliilomo@hotmail.com",
32 | },
33 | ...
34 | ]
35 | ```
36 |
37 | As you can see all fields have been returned as specified on the `UserSerializer`.
38 |
39 | **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.
40 |
41 | For example to select `id` and `username` fields from User model, send a request with a ` query` parameter as shown below.
42 |
43 | `GET /users/?query={id, username}`
44 | ```js
45 | [
46 | {
47 | "id": 1,
48 | "username": "yezyilomo"
49 | },
50 | ...
51 | ]
52 | ```
53 | With this only `id` and `username` fields get returned in a response as specified on a `query` parameter.
54 |
55 |
56 | ## Querying nested fields
57 | **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.
58 |
59 | In an example below we have `location` and `groups` as nested fields on User model.
60 |
61 | ```py
62 | from rest_framework import serializers
63 | from django.contrib.auth.models import User
64 | from django_restql.mixins import DynamicFieldsMixin
65 |
66 | from app.models import GroupSerializer, LocationSerializer
67 |
68 |
69 | class GroupSerializer(DynamicFieldsMixin, serializer.ModelSerializer):
70 | class Meta:
71 | model = Group
72 | fields = ["id", "name"]
73 |
74 |
75 | class LocationSerializer(DynamicFieldsMixin, serializer.ModelSerializer):
76 | class Meta:
77 | model = Location
78 | fields = ["id", "country", "city", "street"]
79 |
80 |
81 | class UserSerializer(DynamicFieldsMixin, serializer.ModelSerializer):
82 | groups = GroupSerializer(many=True, read_only=True)
83 | location = LocationSerializer(many=False, read_only=True)
84 | class Meta:
85 | model = User
86 | fields = ["id", "username", "email", "location", "groups"]
87 | ```
88 |
89 | 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
90 |
91 | `GET /users/?query={id, username, location{country, city}}`
92 | ```js
93 | [
94 | {
95 | "id": 1,
96 | "username": "yezyilomo",
97 | "location": {
98 | "contry": "Tanzania",
99 | "city": "Dar es salaam"
100 | }
101 | },
102 | ...
103 | ]
104 | ```
105 |
106 |