├── 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 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /docs/img/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 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 | ![Build Status](https://github.com/yezyilomo/django-restql/actions/workflows/main.yml/badge.svg?branch=master) 4 | [![Latest Version](https://img.shields.io/pypi/v/django-restql.svg)](https://pypi.org/project/django-restql/) 5 | [![Python Versions](https://img.shields.io/pypi/pyversions/django-restql.svg)](https://pypi.org/project/django-restql/) 6 | [![License](https://img.shields.io/pypi/l/django-restql.svg)](https://pypi.org/project/django-restql/) 7 |        8 | [![Downloads](https://pepy.tech/badge/django-restql)](https://pepy.tech/project/django-restql) 9 | [![Downloads](https://pepy.tech/badge/django-restql/month)](https://pepy.tech/project/django-restql) 10 | [![Downloads](https://pepy.tech/badge/django-restql/week)](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 [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](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 |

More examples to get you comfortable with the query syntax

107 | `GET /users/?query={location, groups}` 108 | ```js 109 | [ 110 | { 111 | "location": { 112 | "id": 1, 113 | "contry": "Tanzania", 114 | "city": "Dar es salaam", 115 | "street": "Oyster Bay" 116 | } 117 | "groups": [ 118 | {"id": 2, "name": "Auth_User"}, 119 | {"id": 3, "name": "Admin_User"} 120 | ] 121 | }, 122 | ... 123 | ] 124 | ``` 125 | 126 | 127 | 128 | `GET /users/?query={id, username, groups{name}}` 129 | ```js 130 | [ 131 | { 132 | "id": 1, 133 | "username": "yezyilomo", 134 | "groups": [ 135 | {"name": "Auth_User"}, 136 | {"name": "Admin_User"} 137 | ] 138 | }, 139 | ... 140 | ] 141 | ``` 142 | 143 | !!! note 144 | Using commas(`,`) to separate fields and arguments is optional, you can use spaces too just like in GraphQL 145 | For example you could write your query as ```query={id username location{country city}}``` so the choice is yours. 146 | 147 | ## Exclude(-) operator 148 | Using **Django RESTQL** filtering as it is when there are no many fields on a serializer is great, but sometimes you might have a case where you would like everything except a handful of fields on a larger serializer. These fields might be nested and trying the whitelist approach might possibly be too long for the url. 149 | 150 | **Django RESTQL** comes with the exclude(-) operator which can be used to exclude some fields in scenarios where you want to get all fields except few ones. Using exclude operator is very simple, you just need to prepend the exclude(-) operator to the field which you want to exclude when writing your query, that's all. 151 | 152 | Take an example below 153 | 154 | ```py 155 | from rest_framework import serializers 156 | from django_restql.mixins import DynamicFieldsMixin 157 | 158 | from app.models import Location, Property 159 | 160 | 161 | class LocationSerializer(DynamicFieldsMixin, serializer.ModelSerializer): 162 | class Meta: 163 | model = Location 164 | fields = ["id", "city", "country", "state", "street"] 165 | 166 | 167 | class PropertySerializer(DynamicFieldsMixin, serializer.ModelSerializer): 168 | location = LocationSerializer(many=False, read_only=True) 169 | class Meta: 170 | model = Property 171 | fields = [ 172 | "id", "price", "location" 173 | ] 174 | ``` 175 | 176 | If we want to get all fields under `LocationSerializer` except `id` and `street`, by using the exclude(-) operator we could do it as follows 177 | 178 | `GET /location/?query={-id, -street}` 179 | ```js 180 | [ 181 | { 182 | "country": "China", 183 | "city": "Beijing", 184 | "state": "Chaoyang" 185 | }, 186 | ... 187 | ] 188 | ``` 189 | This is equivalent to `query={country, city, state}` 190 | 191 | You can use exclude operator on nested fields too, for example if you want to get `price` and `location` fields but under `location` you want all fields except `id` here is how you could do it. 192 | 193 | `GET /property/?query={price, location{-id}}` 194 | ```js 195 | [ 196 | { 197 | "price": 5000 198 | "location": { 199 | "country": "China", 200 | "city" "Beijing", 201 | "state": "Chaoyang", 202 | "street": "Hanang" 203 | } 204 | }, 205 | ... 206 | ] 207 | ``` 208 | This is equivalent to `query={price, location{country, city, state, street}}` 209 | 210 |

More examples to get you comfortable with the exclude(-) operator

211 | Assuming this is the structure of the model we are querying 212 | ```py 213 | data = { 214 | username, 215 | birthdate, 216 | location { 217 | country, 218 | city 219 | }, 220 | contact { 221 | phone, 222 | email 223 | } 224 | } 225 | ``` 226 | 227 | Here is how we can structure our queries to exclude some fields by using exclude(-) operator 228 | ```py 229 | {-username} ≡ {birthdate, location{country, city}, contact{phone, email}} 230 | 231 | {-username, contact{phone}, location{country}} ≡ {birthdate ,contact{phone}, location{country}} 232 | 233 | {-contact, location{country}} ≡ {username, birthdate, location{country}} 234 | 235 | {-contact, -location} ≡ {username, birthdate} 236 | 237 | {username, location{-country}} ≡ {username, location{city}} 238 | 239 | {username, location{-city}, contact{-email}} ≡ {username, location{country}, contact{phone}} 240 | ``` 241 | 242 | ## Wildcard(*) operator 243 | In addition to the exclude(-) operator, **Django RESTQL** comes with a wildcard(\*) operator for including all fields. Using a wildcard(\*) operator is very simple, for example if you want to get all fields from a model by using a wildcard(\*) operator you could simply write your query as 244 | 245 | `query={*}` 246 | 247 | This operator can be used to simplify some filtering which might endup being very long if done with other approaches. For example if you have a model with this format 248 | 249 | ```py 250 | user = { 251 | username, 252 | birthdate, 253 | contact { 254 | phone, 255 | email, 256 | twitter, 257 | github, 258 | linkedin, 259 | facebook 260 | } 261 | } 262 | ``` 263 | Let's say you want to get all user fields but under `contact` field you want to get only `phone`, you could use the whitelisting approach and write your query as 264 | 265 | `query={username, birthdate, contact{phone}}` 266 | 267 | but if you have many fields on user model you might endup writing a very long query, such problem can be avoided by using a wildcard(\*) operator which in our case we could simply write the query as 268 | 269 | `query={*, contact{phone}}` 270 | 271 | The above query means "get me all fields on user model but under `contact` field get only `phone` field". As you can see the query became very short compared to the first one after using wildcard(\*) operator and it won't grow if more fields are added to a user model. 272 | 273 |

More examples to get you comfortable with the wildcard(*) operator

274 | ```py 275 | {*, -username, contact{phone}} ≡ {birthdate, contact{phone}} 276 | 277 | {username, contact{*, -facebook, -linkedin}} ≡ {username, contact{phone, email, twitter, github}} 278 | 279 | {*, -username, contact{*, -facebook, -linkedin}} ≡ {birthdate, contact{phone, email, twitter, github}} 280 | ``` 281 | 282 | 283 | Below is a list of mistakes which leads to query syntax/format error, these mistakes may happen accidentally as it's very easy/tempting to make them with the exclude(-) operator and wildcard(*) operator syntax. 284 | ```py 285 | {username, -location{country}} # Should not expand excluded field 286 | {*username} # What are you even trying to accomplish 287 | {*location{country}} # This is definitely wrong 288 | ``` 289 | 290 | 291 | ## Aliases 292 | When working with an API, you may want to rename a field to something more suitable than what the API provides. Aliases let you do exactly that—without changing the backend. 293 | 294 | Aliases are defined on the client side, so you can freely rename fields in your query without touching the API code. 295 | 296 | Imagine requesting data using the following query from an API: 297 | 298 | `GET /users/?query={id, updated_at}` 299 | 300 | You will get the following JSON response: 301 | 302 | ```js 303 | [ 304 | { 305 | "id": 1, 306 | "updated_at": "2021-05-05T21:05:23.034Z" 307 | }, 308 | ... 309 | ] 310 | ``` 311 | 312 | Here the `updated_at` field works but it doesn’t quite conform to the camel case convention in JavaScript(Which is where APIs are used mostly). Let’s rename it with an alias. 313 | 314 | `GET /users/?query={id, updatedAt: updated_at}` 315 | 316 | Response: 317 | 318 | ```js 319 | [ 320 | { 321 | "id": 1, 322 | "updatedAt": "2021-05-05T21:05:23.034Z" 323 | }, 324 | ... 325 | ] 326 | ``` 327 | 328 | Creating an alias is very easy just like in [GraphQL](https://graphql.org/learn/queries/#aliases). Simply add a new name and a colon(:) before the field you want to rename. 329 | 330 |

More examples

331 | 332 | Renaming `date_of_birth` to `dateOfBirth`, `course` to `programme` and `books` to `readings` 333 | 334 | `GET /students/?query={name, dateOfBirth: date_of_birth, programme: course{id, name, readings: books}}` 335 | 336 | This yields 337 | 338 | ```js 339 | [ 340 | { 341 | "name": "Yezy Ilomo", 342 | "dateOfBirth": "04-08-1995", 343 | "programme": { 344 | "id": 4, 345 | "name": "Computer Science", 346 | "readings": [ 347 | {"id": 1, "title": "Alogarithms"}, 348 | {"id": 2, "title": "Data Structures"}, 349 | ] 350 | } 351 | }, 352 | ... 353 | ] 354 | ``` 355 | 356 | !!! note 357 | The default maximum alias length is 50 characters, it's controlled by `MAX_ALIAS_LEN` setting. This is enforced to prevent DoS like attacks to API which might be caused by a client specifying a really long alias which may increase network usage. For more information about `MAX_ALIAS_LEN` setting and how to change it see [this section](/django-restql/settings/#max_alias_len). 358 | 359 | 360 | ## DynamicSerializerMethodField 361 | `DynamicSerializerMethodField` is a wrapper around DRF’s `SerializerMethodField`. 362 | Its main advantage is that it passes the parsed query from the parent serializer into the method, enabling you to apply further querying to nested serializers inside that method. 363 | 364 | This is especially useful when you want to make a field returned by `SerializerMethodField` queryable. 365 | 366 | For example in the scenario below we are using `DynamicSerializerMethodField` because we want to be able to query `related_books` field. 367 | 368 | ```py 369 | from django_restql.mixins import DynamicFieldsMixin 370 | from django_restql.fields import DynamicSerializerMethodField 371 | 372 | 373 | class CourseSerializer(DynamicFieldsMixin, serializers.ModelSerializer): 374 | # Use `DynamicSerializerMethodField` instead of `SerializerMethodField` 375 | # if you want to be able to query `related_books` 376 | related_books = DynamicSerializerMethodField() 377 | class Meta: 378 | model = Course 379 | fields = ["name", "code", "related_books"] 380 | 381 | def get_related_books(self, obj, parsed_query): 382 | # With `DynamicSerializerMethodField` you get this extra 383 | # `parsed_query` argument in addition to `obj` 384 | books = obj.books.all() 385 | 386 | # You can do what ever you want in here 387 | 388 | # `parsed_query` param is passed to BookSerializer to allow further querying 389 | serializer = BookSerializer( 390 | books, 391 | many=True, 392 | parsed_query=parsed_query 393 | ) 394 | return serializer.data 395 | ``` 396 | 397 | `GET /course/?query={name, related_books}` 398 | ```js 399 | [ 400 | { 401 | "name": "Data Structures", 402 | "related_books": [ 403 | {"title": "Advanced Data Structures", "author": "S.Mobit"}, 404 | {"title": "Basic Data Structures", "author": "S.Mobit"} 405 | ] 406 | } 407 | ] 408 | ``` 409 | 410 | `GET /course/?query={name, related_books{title}}` 411 | ```js 412 | [ 413 | { 414 | "name": "Data Structures", 415 | "related_books": [ 416 | {"title": "Advanced Data Structures"}, 417 | {"title": "Basic Data Structures"} 418 | ] 419 | } 420 | ] 421 | ``` 422 | 423 | With `DynamicSerializerMethodField`, you can make even custom, computed fields queryable — giving your API consumers fine-grained control over the response. 424 | 425 | 426 | ## DynamicFieldsMixin kwargs 427 | `DynamicFieldsMixin` accepts extra kwargs in addition to those accepted by a serializer, these extra kwargs can be used to do more customizations on a serializer as explained below. 428 | 429 | ### fields kwarg 430 | With **Django RESTQL** you can specify fields to be included when instantiating a serializer, this provides a way to refilter fields on nested fields(i.e you can opt to remove some fields on a nested field). Below is an example which shows how you can specify fields to be included on nested resources. 431 | 432 | ```py 433 | from rest_framework import serializers 434 | from django.contrib.auth.models import User 435 | from django_restql.mixins import DynamicFieldsMixin 436 | 437 | from app.models import Book, Course 438 | 439 | 440 | class BookSerializer(DynamicFieldsMixin, serializers.ModelSerializer): 441 | class Meta: 442 | model = Book 443 | fields = ["id", "title", "author"] 444 | 445 | 446 | class CourseSerializer(DynamicFieldsMixin, serializers.ModelSerializer): 447 | books = BookSerializer(many=True, read_only=True, fields=["title"]) 448 | class Meta: 449 | model = Course 450 | fields = ["name", "code", "books"] 451 | ``` 452 | 453 | `GET /courses/` 454 | ```js 455 | [ 456 | { 457 | "name": "Computer Programming", 458 | "code": "CS50", 459 | "books": [ 460 | {"title": "Computer Programming Basics"}, 461 | {"title": "Data structures"} 462 | ] 463 | }, 464 | ... 465 | ] 466 | ``` 467 | As you see from the response above, the nested resource(book) has only one field(title) as specified on `fields=["title"]` kwarg during instantiating BookSerializer, so if you send a request like 468 | 469 | `GET /course?query={name, code, books{title, author}}` 470 | 471 | you will get an error that `author` field is not found because it was not included in `fields`. 472 | 473 | 474 | ### exclude kwarg 475 | You can also specify fields to be excluded when instantiating a serializer by using `exclude` kwarg, below is an example which shows how to use `exclude` kwarg. 476 | 477 | ```py 478 | from rest_framework import serializers 479 | from django_restql.mixins import DynamicFieldsMixin 480 | 481 | from app.models import Book, Course 482 | 483 | 484 | class BookSerializer(DynamicFieldsMixin, serializers.ModelSerializer): 485 | class Meta: 486 | model = Book 487 | fields = ["id", "title", "author"] 488 | 489 | 490 | class CourseSerializer(DynamicFieldsMixin, serializers.ModelSerializer): 491 | books = BookSerializer(many=True, read_only=True, exclude=["author"]) 492 | class Meta: 493 | model = Course 494 | fields = ["name", "code", "books"] 495 | ``` 496 | 497 | `GET /courses/` 498 | ```js 499 | [ 500 | { 501 | "name": "Computer Programming", 502 | "code": "CS50", 503 | "books": [ 504 | {"id": 1, "title": "Computer Programming Basics"}, 505 | {"id": 2, "title": "Data structures"} 506 | ] 507 | }, 508 | ... 509 | ] 510 | ``` 511 | From the response above you can see that `author` field has been excluded fom book nested resource as specified on `exclude=["author"]` kwarg during instantiating BookSerializer. 512 | 513 | !!! note 514 | `fields` and `exclude` kwargs have no effect when you access the resources directly, so when you access books you will still get all fields i.e 515 | 516 | `GET /books/` 517 | ```js 518 | [ 519 | { 520 | "id": 1, 521 | "title": "Computer Programming Basics", 522 | "author": "S.Mobit" 523 | }, 524 | ... 525 | ] 526 | ``` 527 | You can see that all fields have appeared as specified on `fields = ["id", "title", "author"]` on BookSerializer class. 528 | 529 | 530 | ### query kwarg 531 | **Django RESTQL** allows you to query fields by using `query` kwarg too, this is used if you don't want to get your query string from a request parameter, in fact `DynamicFieldsMixin` can work independently without using request. So by using `query` kwarg if you have serializers like 532 | 533 | ```py 534 | from rest_framework import serializers 535 | from django_restql.mixins import DynamicFieldsMixin 536 | 537 | from app.models import Book, Course 538 | 539 | 540 | class BookSerializer(DynamicFieldsMixin, serializers.ModelSerializer): 541 | class Meta: 542 | model = Book 543 | fields = ["id", "title", "author"] 544 | 545 | 546 | class CourseSerializer(DynamicFieldsMixin, serializers.ModelSerializer): 547 | books = BookSerializer(many=True, read_only=True, exclude=["author"]) 548 | class Meta: 549 | model = Course 550 | fields = ["name", "code", "books"] 551 | ``` 552 | 553 | You can query fields as 554 | 555 | ```py 556 | objs = Course.objects.all() 557 | query = "{name, books{title}}" 558 | serializer = CourseSerializer(objs, many=True, query=query) 559 | print(serializer.data) 560 | 561 | # This will print 562 | [ 563 | { 564 | "name": "Computer Programming", 565 | "books": [ 566 | {"title": "Computer Programming Basics"}, 567 | {"title": "Data structures"} 568 | ] 569 | }, 570 | ... 571 | ] 572 | ``` 573 | 574 | As you see this doesn't need a request or view to work, you can use it anywhere as long as you pass your query string as a `query` kwarg. 575 | 576 | 577 | ### parsed_query kwarg 578 | In addition to `query` kwarg, **Django RESTQL** allows you to query fields by using `parsed_query` kwarg. Here `parsed_query` is a query which has been parsed by a `QueryParser`. You probably won't need to use this directly as you are not adviced to write parsed query yourself, so the value of `parsed_query` kwarg should be something coming from `QueryParser`. If you have serializers like 579 | 580 | ```py 581 | from rest_framework import serializers 582 | from django_restql.mixins import DynamicFieldsMixin 583 | 584 | from app.models import Book, Course 585 | 586 | 587 | class BookSerializer(DynamicFieldsMixin, serializers.ModelSerializer): 588 | class Meta: 589 | model = Book 590 | fields = ["id", "title", "author"] 591 | 592 | 593 | class CourseSerializer(DynamicFieldsMixin, serializers.ModelSerializer): 594 | books = BookSerializer(many=True, read_only=True, exclude=["author"]) 595 | class Meta: 596 | model = Course 597 | fields = ["name", "code", "books"] 598 | ``` 599 | 600 | You can query fields by using `parsed_query` kwarg as follows 601 | 602 | ```py 603 | import QueryParser from django_restql.parser 604 | 605 | objs = Course.objects.all() 606 | query = "{name, books{title}}" 607 | 608 | # You have to parse your query string first 609 | parser = QueryParser() 610 | parsed_query = parser.parse(query) 611 | 612 | serializer = CourseSerializer(objs, many=True, parsed_query=parsed_query) 613 | print(serializer.data) 614 | 615 | # This will print 616 | [ 617 | { 618 | "name": "Computer Programming", 619 | "books": [ 620 | {"title": "Computer Programming Basics"}, 621 | {"title": "Data structures"} 622 | ] 623 | }, 624 | ... 625 | ] 626 | ``` 627 | 628 | `parsed_query` kwarg is often used with `DynamicMethodField` to pass part of the parsed query to nested fields to allow further querying. 629 | 630 | 631 | ### return_pk kwarg 632 | 633 | With **Django RESTQL**, you can control whether nested resources return their full data or just their primary keys (PKs) by using the `return_pk` keyword argument. 634 | 635 | Below is an example showing how to use `return_pk` kwarg: 636 | 637 | ```py 638 | from rest_framework import serializers 639 | from django_restql.mixins import DynamicFieldsMixin 640 | 641 | from app.models import Book, Course 642 | 643 | 644 | class BookSerializer(DynamicFieldsMixin, serializers.ModelSerializer): 645 | class Meta: 646 | model = Book 647 | fields = ["id", "title", "author"] 648 | 649 | 650 | class CourseSerializer(DynamicFieldsMixin, serializers.ModelSerializer): 651 | books = BookSerializer(many=True, read_only=True, return_pk=True) 652 | class Meta: 653 | model = Course 654 | fields = ["name", "code", "books"] 655 | ``` 656 | 657 | `GET /course/` 658 | ```js 659 | [ 660 | { 661 | "name": "Computer Programming", 662 | "code": "CS50", 663 | "books": [1, 2] 664 | }, 665 | ... 666 | ] 667 | ``` 668 | So you can see that on the nested field `books` pks have been returned instead of the full book data because `return_pk=True` was set on the `BookSerializer` inside `CourseSerializer`. 669 | 670 | 671 | ### disable_dynamic_fields kwarg 672 | Sometimes there are cases where you want to disable fields filtering on a specific nested field, **Django RESTQL** allows you to do so by using `disable_dynamic_fields` kwarg when instantiating a serializer. Below is an example which shows how to use `disable_dynamic_fields` kwarg. 673 | 674 | ```py 675 | from rest_framework import serializers 676 | from django_restql.mixins import DynamicFieldsMixin 677 | 678 | from app.models import Book, Course 679 | 680 | 681 | class BookSerializer(DynamicFieldsMixin, serializers.ModelSerializer): 682 | class Meta: 683 | model = Book 684 | fields = ["id", "title", "author"] 685 | 686 | 687 | class CourseSerializer(DynamicFieldsMixin, serializers.ModelSerializer): 688 | # Disable fields filtering on this field 689 | books = BookSerializer(many=True, read_only=True, disable_dynamic_fields=True) 690 | class Meta: 691 | model = Course 692 | fields = ["name", "code", "books"] 693 | ``` 694 | 695 | `GET /course/?query={name, books{title}}` 696 | ```js 697 | [ 698 | { 699 | "name": "Computer Programming", 700 | "books": [ 701 | {"id": 1, "title": "Computer Programming Basics", "author": "J.Vough"}, 702 | {"id": 2, "title": "Data structures", "author": "D.Denis"} 703 | ] 704 | }, 705 | ... 706 | ] 707 | ``` 708 | 709 | In this example, even though the query requested only the `title` field for `books`, all fields were returned. This is because `disable_dynamic_fields=True` was set on the `BookSerializer` within `CourseSerializer`. Field filtering still applied to `CourseSerializer`, but not to its nested `BookSerializer`. 710 | 711 | 712 | ## Query arguments 713 | Just like GraphQL, Django RESTQL allows you to pass arguments. These arguments can be used to do filtering, pagination, sorting and other stuffs that you would like them to do. Below is a syntax for passing arguments 714 | 715 | ``` 716 | query = (age: 18){ 717 | name, 718 | age, 719 | location(country: Canada, city: Toronto){ 720 | country, 721 | city 722 | } 723 | } 724 | ``` 725 | Here we have three arguments, `age`, `country` and `city` and their corresponding values. 726 | 727 | To escape any special character in a string(including `, : " ' {} ()`) use backslash `\`, single quote `'` or double quote `"`, also if you want to escape double quote you can use single quote and vice versa. Escaping is very useful if you are dealing with data containing special characters e.g time, dates, lists, texts etc. Below is an example which contain an argument with a date type. 728 | 729 | ``` 730 | query = (age: 18, join_date__lt: "2020-04-27T23:02:32Z"){ 731 | name, 732 | age, 733 | location(country: "Canada", city: "Toronto"){ 734 | country, 735 | city 736 | } 737 | } 738 | ``` 739 | 740 | 741 | ### Query arguments data types 742 | Django RESTQL supports five primitive data types for query arguments which are `String`, `Int`, `Float`, `Boolean`, and `null` 743 | 744 | The table below shows possible argument values and their corresponding python values 745 | 746 | | Argument Value | Python Value | 747 | |-------------------------------------|-----------------------------------| 748 | | String(e.g "Hi!" or 'Hi!') | Python String(e.g "Hi!" or 'Hi!') | 749 | | Int(e.g 25) | Python Int(e.g 25) | 750 | | Float(e.g 25.34) | Python Float(e.g 25.34) | 751 | | true | True | 752 | | false | False | 753 | | null | None | 754 | 755 | Below is a query showing how these data types are used 756 | 757 | ``` 758 | query = (age__gt: 18, is_active: true, location__ne: null, height__gt: 5.4){ 759 | name, 760 | age, 761 | location(country: "Canada"){ 762 | country, 763 | city 764 | } 765 | } 766 | ``` 767 | 768 | 769 | ### Filtering & pagination with query arguments 770 | As mentioned before you can use query arguments to do filtering and pagination, Django RESTQL itself doesn't do filtering or pagination but it can help you to convert query arguments into query parameters from there you can use any library which you want to do the actual filtering or any pagination class to do pagination as long as they work with query parameters. To convert query arguments into query parameters all you need to do is inherit `QueryArgumentsMixin` in your viewset, that's it. For example 771 | 772 | ```py 773 | # views.py 774 | 775 | from rest_framework import viewsets 776 | from django_restql.mixins import QueryArgumentsMixin 777 | 778 | class StudentViewSet(QueryArgumentsMixin, viewsets.ModelViewSet): 779 | serializer_class = StudentSerializer 780 | queryset = Student.objects.all() 781 | filter_fields = { 782 | "name": ["exact"], 783 | "age": ["exact"], 784 | "location__country": ["exact"], 785 | "location__city": ["exact"], 786 | } 787 | ``` 788 | 789 | Whether you are using [django-filter](https://github.com/carltongibson/django-filter) or [djangorestframework-filters](https://github.com/philipn/django-rest-framework-filters) or any filter backend to do the actual filtering, Once you've configured it, you can continue to use all of the features found in filter backend of your choise as usual. The purpose of Django RESTQL on filtering is only to generate query parameters form query arguments. For example if you have a query like 790 | 791 | ``` 792 | query = (age: 18){ 793 | name, 794 | age, 795 | location(country: Canada, city: Toronto){ 796 | country, 797 | city 798 | } 799 | } 800 | ``` 801 | 802 | Django RESTQL would generate three query parameters from this as shown below 803 | ```py 804 | query_params = {"age": 18, "location__country": "Canada", "location__city": "Toronto"} 805 | ``` 806 | These will be used by the filter backend you have set to do the actual filtering. 807 | 808 | The same applies to pagination, sorting etc, once you have configured your pagination class whether it's `PageNumberPagination`, `LimitOffsetPagination`, `CursorPagination` or a custom, you will be able do it with query arguments. For example if you're using `LimitOffsetPagination` and you have a query like 809 | 810 | ``` 811 | query = (limit: 20, offset: 50){ 812 | name, 813 | age, 814 | location{ 815 | country, 816 | city 817 | } 818 | } 819 | ``` 820 | 821 | Django RESTQL would generate two query parameters from this as shown below 822 | ```py 823 | query_params = {"limit": 20, "offset": 50} 824 | ``` 825 | These will be used by pagination class you have set to do the actual pagination. 826 | 827 | So to use query arguments as query parameters all you need to do is inherit `QueryArgumentsMixin` to your viewset to convert query arguments into query parameters, from there you can use whatever you want to accomplish whatever with those generated query parameters. 828 | 829 | 830 | ## Setting up eager loading 831 | Often times, using `prefetch_related` or `select_related` on a view queryset can help speed up the serialization. For example, if you had a many-to-many relation like Books to a Course, it's usually more efficient to call `prefetch_related` on the books so that serializing a list of courses only triggers one additional query, instead of a number of queries equal to the number of courses. 832 | 833 | `EagerLoadingMixin` gives access to `prefetch_related` and `select_related` properties, these two are dictionaries that match serializer field names to respective values that would be passed into `prefetch_related` or `select_related`. Take the following serializers as examples. 834 | 835 | ```py 836 | class CourseSerializer(DynamicFieldsMixin, serializers.ModelSerializer): 837 | books = BookSerializer(many=True, read_only=True) 838 | 839 | class Meta: 840 | model = Course 841 | fields = ["name", "code", "books"] 842 | 843 | class StudentSerializer(DynamicFieldsMixin, serializers.ModelSerializer): 844 | program = CourseSerializer(source="course", many=False, read_only=True) 845 | phone_numbers = PhoneSerializer(many=True, read_only=True) 846 | 847 | class Meta: 848 | model = Student 849 | fields = ["name", "age", "program", "phone_numbers"] 850 | ``` 851 | 852 | In a view, these can be used as described earlier in this documentation. However, if prefetching of `books` always happened, but we did not ask for `{program}` or `program{books}`, then we did an additional query for nothing. Conversely, not prefetching can lead to even more queries being triggered. When leveraging the `EagerLoadingMixin` on a view, the specific fields that warrant a `select_related` or `prefetch_related` can be described. 853 | 854 | 855 | ### Syntax for prefetch_related and select_related 856 | The format of syntax for `select_related` and `prefetch_related` is as follows 857 | 858 | ```py 859 | select_related = {"serializer_field_name": ["field_to_select"]} 860 | prefetch_related = {"serializer_field_name": ["field_to_prefetch"]} 861 | ``` 862 | 863 | If you are selecting or prefetching one field per serializer field name you can use 864 | ```py 865 | select_related = {"serializer_field_name": "field_to_select"} 866 | prefetch_related = {"serializer_field_name": "field_to_prefetch"} 867 | ``` 868 | 869 | **Syntax Interpretation** 870 | 871 | * `serializer_field_name` stands for the name of the field to prefetch or select(as named on a serializer). 872 | * `fields_to_select` stands for argument(s) to pass when calling `select_related` method. 873 | * `fields_to_prefetch` stands for arguments(s) to pass when calling `prefetch_related` method. This can be a string or `Prefetch` object. 874 | * If you want to select or prefetch nested field use dot(.) to separate parent and child fields on `serializer_field_name` eg `parent.child`. 875 | 876 | 877 | ### Example of EagerLoadingMixin usage 878 | 879 | ```py 880 | from rest_framework import viewsets 881 | from django_restql.mixins import EagerLoadingMixin 882 | from myapp.serializers import StudentSerializer 883 | from myapp.models import Student 884 | 885 | class StudentViewSet(EagerLoadingMixin, viewsets.ModelViewSet): 886 | serializer_class = StudentSerializer 887 | queryset = Student.objects.all() 888 | 889 | # The Interpretation of this is 890 | # Select `course` only if program field is included in a query 891 | select_related = { 892 | "program": "course" 893 | } 894 | 895 | # The Interpretation of this is 896 | # Prefetch `course__books` only if program or program.books 897 | # fields are included in a query 898 | prefetch_related = { 899 | "program.books": "course__books" 900 | } 901 | ``` 902 | 903 |

Example Queries

904 | 905 | - `{name}`:    Neither `select_related` or `prefetch_related` will be run since neither field is present on the serializer for this query. 906 | 907 | - `{program}`:    Both `select_related` and `prefetch_related` will be run, since `program` is present in its entirety (including the `books` field). 908 | 909 | - `{program{name}}`:    Only `select_related` will be run, since `books` are not present on the program fields. 910 | 911 | - `{program{books}}`:    Both will be run here as well, since this explicitly fetches books. 912 | 913 |

More example to get you comfortable with the syntax

914 | Assuming this is the structure of the model and corresponding field types 915 | 916 | ```py 917 | user = { 918 | username, # string 919 | birthdate, # string 920 | location { # foreign key related field 921 | country, # string 922 | city # string 923 | }, 924 | contact { # foreign key related field 925 | email, # string 926 | phone { # foreign key related field 927 | number, # string 928 | type # string 929 | } 930 | } 931 | articles { # many related field 932 | title, # string 933 | body, # text 934 | reviews { # many related field 935 | comment, # string 936 | rating # number 937 | } 938 | } 939 | } 940 | ``` 941 | 942 | Here is how `select_related` and `prefetch_related` could be written for this model 943 | ```py 944 | select_related = { 945 | "location": "location", 946 | "contact": "contact", 947 | "contact.phone": "contact__phone" 948 | } 949 | 950 | prefetch_related = { 951 | "articles": Prefetch("articles", queryset=Article.objects.all()), 952 | "articles.reviews": "articles__reviews" 953 | } 954 | ``` 955 | 956 | ### Known Caveats 957 | When prefetching with a `to_attr`, ensure that there are no collisions. Django does not allow multiple prefetches with the same `to_attr` on the same queryset. 958 | 959 | When prefetching *and* calling `select_related` on a field, Django may error, since the ORM does allow prefetching a selectable field, but not both at the same time. -------------------------------------------------------------------------------- /django_restql/mixins.py: -------------------------------------------------------------------------------- 1 | from django.http import QueryDict 2 | from django.db.models import Prefetch 3 | from django.utils.functional import cached_property 4 | from django.core.exceptions import ObjectDoesNotExist 5 | from django.db.models.fields.related import ManyToOneRel, ManyToManyRel 6 | from rest_framework.serializers import Serializer, ListSerializer, ValidationError 7 | 8 | from .settings import restql_settings 9 | from .parser import Query, QueryParser 10 | from .exceptions import FieldNotFound, QueryFormatError 11 | from .operations import ADD, CREATE, DELETE, REMOVE, UPDATE 12 | from .fields import ( 13 | ALL_RELATED_OBJS, TemporaryNestedField, BaseRESTQLNestedField, 14 | DynamicSerializerMethodField 15 | ) 16 | 17 | 18 | try: 19 | from django.contrib.contenttypes.fields import GenericRel 20 | from django.contrib.contenttypes.models import ContentType 21 | except (RuntimeError, ImportError): 22 | GenericRel = None 23 | ContentType = None 24 | 25 | 26 | class RequestQueryParserMixin(object): 27 | """ 28 | Mixin for parsing restql query from request. 29 | 30 | NOTE: We are using `request.GET` instead of 31 | `request.query_params` because this might be 32 | called before DRF request is created(i.e from dispatch). 33 | This means `request.query_params` might not be available 34 | when this mixin is used. 35 | """ 36 | 37 | @classmethod 38 | def has_restql_query_param(cls, request): 39 | query_param_name = restql_settings.QUERY_PARAM_NAME 40 | return query_param_name in request.GET 41 | 42 | @classmethod 43 | def get_parsed_restql_query_from_req(cls, request): 44 | if hasattr(request, "parsed_restql_query"): 45 | # Use cached parsed restql query 46 | return request.parsed_restql_query 47 | raw_query = request.GET[restql_settings.QUERY_PARAM_NAME] 48 | parser = QueryParser() 49 | parsed_restql_query = parser.parse(raw_query) 50 | 51 | # Save parsed restql query to the request so that 52 | # we won't need to parse it again if needed later 53 | request.parsed_restql_query = parsed_restql_query 54 | return parsed_restql_query 55 | 56 | 57 | class QueryArgumentsMixin(RequestQueryParserMixin): 58 | """Mixin for converting query arguments into query parameters""" 59 | 60 | def get_parsed_restql_query(self, request): 61 | if self.has_restql_query_param(request): 62 | try: 63 | return self.get_parsed_restql_query_from_req(request) 64 | except (SyntaxError, QueryFormatError): 65 | # Let `DynamicFieldsMixin` handle this for a user 66 | # to get a helpful error message 67 | pass 68 | 69 | # Else include all fields 70 | query = Query( 71 | field_name=None, 72 | included_fields=["*"], 73 | excluded_fields=[], 74 | aliases={}, 75 | arguments={}, 76 | ) 77 | return query 78 | 79 | def build_query_params(self, parsed_query, parent=None): 80 | query_params = {} 81 | prefix = "" 82 | if parent is None: 83 | query_params.update(parsed_query.arguments) 84 | else: 85 | prefix = parent + "__" 86 | for argument, value in parsed_query.arguments.items(): 87 | name = prefix + argument 88 | query_params.update({name: value}) 89 | 90 | for field in parsed_query.included_fields: 91 | if isinstance(field, Query): 92 | nested_query_params = self.build_query_params( 93 | field, parent=prefix + field.field_name 94 | ) 95 | query_params.update(nested_query_params) 96 | return query_params 97 | 98 | def inject_query_params_in_req(self, request): 99 | parsed = self.get_parsed_restql_query(request) 100 | 101 | # Generate query params from query arguments 102 | query_params = self.build_query_params(parsed) 103 | 104 | # We are using `request.GET` instead of `request.query_params` 105 | # because at this point DRF request is not yet created so 106 | # `request.query_params` is not yet available 107 | params = request.GET.copy() 108 | params.update(query_params) 109 | 110 | # Make QueryDict immutable after updating 111 | request.GET = QueryDict(params.urlencode(), mutable=False) 112 | 113 | def dispatch(self, request, *args, **kwargs): 114 | self.inject_query_params_in_req(request) 115 | return super().dispatch(request, *args, **kwargs) 116 | 117 | 118 | class DynamicFieldsMixin(RequestQueryParserMixin): 119 | def __init__(self, *args, **kwargs): 120 | # Don't pass DynamicFieldsMixin's kwargs to the superclass 121 | self.dynamic_fields_mixin_kwargs = { 122 | "query": kwargs.pop("query", None), 123 | "parsed_query": kwargs.pop("parsed_query", None), 124 | "fields": kwargs.pop("fields", None), 125 | "exclude": kwargs.pop("exclude", None), 126 | "return_pk": kwargs.pop("return_pk", False), 127 | "disable_dynamic_fields": kwargs.pop("disable_dynamic_fields", False), 128 | } 129 | 130 | msg = "May not set both `fields` and `exclude` kwargs" 131 | assert not ( 132 | self.dynamic_fields_mixin_kwargs["fields"] is not None 133 | and self.dynamic_fields_mixin_kwargs["exclude"] is not None 134 | ), msg 135 | 136 | msg = "May not set both `query` and `parsed_query` kwargs" 137 | assert not ( 138 | self.dynamic_fields_mixin_kwargs["query"] is not None 139 | and self.dynamic_fields_mixin_kwargs["parsed_query"] is not None 140 | ), msg 141 | 142 | # flag to toggle using restql fields 143 | self.is_ready_to_use_dynamic_fields = False 144 | 145 | # Instantiate the superclass normally 146 | super().__init__(*args, **kwargs) 147 | 148 | def to_representation(self, instance): 149 | # Activate using restql fields 150 | self.is_ready_to_use_dynamic_fields = True 151 | 152 | if self.dynamic_fields_mixin_kwargs["return_pk"]: 153 | return instance.pk 154 | return super().to_representation(instance) 155 | 156 | @cached_property 157 | def allowed_fields(self): 158 | fields = super().fields 159 | if self.dynamic_fields_mixin_kwargs["fields"] is not None: 160 | # Drop all fields which are not specified on the `fields` kwarg. 161 | allowed = set(self.dynamic_fields_mixin_kwargs["fields"]) 162 | existing = set(fields) 163 | not_allowed = existing.symmetric_difference(allowed) 164 | for field_name in not_allowed: 165 | try: 166 | fields.pop(field_name) 167 | except KeyError: 168 | msg = "Field `%s` is not found" % field_name 169 | raise FieldNotFound(msg) from None 170 | 171 | if self.dynamic_fields_mixin_kwargs["exclude"] is not None: 172 | # Drop all fields specified on the `exclude` kwarg. 173 | not_allowed = set(self.dynamic_fields_mixin_kwargs["exclude"]) 174 | for field_name in not_allowed: 175 | try: 176 | fields.pop(field_name) 177 | except KeyError: 178 | msg = "Field `%s` is not found" % field_name 179 | raise FieldNotFound(msg) from None 180 | return fields 181 | 182 | @staticmethod 183 | def is_field_found(field_name, all_field_names, raise_exception=False): 184 | if field_name in all_field_names: 185 | return True 186 | else: 187 | if raise_exception: 188 | msg = "`%s` field is not found" % field_name 189 | raise ValidationError(msg, code="not_found") 190 | return False 191 | 192 | @staticmethod 193 | def is_nested_field(field_name, field, raise_exception=False): 194 | nested_classes = (Serializer, ListSerializer, DynamicSerializerMethodField) 195 | if isinstance(field, nested_classes): 196 | return True 197 | else: 198 | if raise_exception: 199 | msg = "`%s` is not a nested field" % field_name 200 | raise ValidationError(msg, code="invalid") 201 | return False 202 | 203 | @staticmethod 204 | def is_valid_alias(alias): 205 | if len(alias) > restql_settings.MAX_ALIAS_LEN: 206 | msg = ( 207 | "The length of `%s` alias has exceeded " 208 | "the limit specified, which is %s characters." 209 | ) % (alias, restql_settings.MAX_ALIAS_LEN) 210 | raise ValidationError(msg, code="invalid") 211 | 212 | def rename_aliased_fields(self, aliases, all_fields): 213 | for field, alias in aliases.items(): 214 | self.is_field_found(field, all_fields, raise_exception=True) 215 | self.is_valid_alias(alias) 216 | all_fields[alias] = all_fields[field] 217 | return all_fields 218 | 219 | def select_fields(self, parsed_query, all_fields): 220 | self.rename_aliased_fields(parsed_query.aliases, all_fields) 221 | 222 | # The format is [field1, field2 ...] 223 | allowed_flat_fields = [] 224 | 225 | # The format is {nested_field: [sub_fields ...] ...} 226 | allowed_nested_fields = {} 227 | 228 | # The parsed_query.excluded_fields 229 | # is a list of names of excluded fields 230 | # The format is [field1, field2 ...] 231 | excluded_fields = parsed_query.excluded_fields 232 | 233 | # The parsed_query.included_fields 234 | # contains a list of allowed fields, 235 | # The format is [field, {nested_field: [sub_fields ...]} ...] 236 | included_fields = parsed_query.included_fields 237 | 238 | include_all_fields = False # Assume the * is not set initially 239 | 240 | # Go through all included fields to check if 241 | # they are all valid and to set `nested_fields` 242 | # property on parent fields for future reference 243 | for field in included_fields: 244 | if field == "*": 245 | # Include all fields but ignore `*` since 246 | # it's not an actual field(it's just a flag) 247 | include_all_fields = True 248 | continue 249 | if isinstance(field, Query): 250 | # Nested field 251 | alias = parsed_query.aliases.get(field.field_name, field.field_name) 252 | 253 | self.is_field_found(field.field_name, all_fields, raise_exception=True) 254 | self.is_nested_field( 255 | field.field_name, all_fields[field.field_name], raise_exception=True 256 | ) 257 | allowed_nested_fields.update({alias: field}) 258 | else: 259 | # Flat field 260 | alias = parsed_query.aliases.get(field, field) 261 | self.is_field_found(field, all_fields, raise_exception=True) 262 | allowed_flat_fields.append(alias) 263 | 264 | def get_duplicates(items): 265 | unique = [] 266 | repeated = [] 267 | for item in items: 268 | if item not in unique: 269 | unique.append(item) 270 | else: 271 | repeated.append(item) 272 | return repeated 273 | 274 | included_and_excluded_fields = ( 275 | allowed_flat_fields + list(allowed_nested_fields.keys()) + excluded_fields 276 | ) 277 | 278 | including_or_excluding_field_more_than_once = len( 279 | included_and_excluded_fields 280 | ) != len(set(included_and_excluded_fields)) 281 | 282 | if including_or_excluding_field_more_than_once: 283 | repeated_fields = get_duplicates(included_and_excluded_fields) 284 | msg = ( 285 | "QueryFormatError: You have either " 286 | "included/excluded a field more than once, " # e.g {id, id} 287 | "used the same alias more than once, " # e.g {x: name, x: age} 288 | "used a field name as an alias to another field or " # e.g {id, id: age} Here age's not a parent 289 | "renamed a field and included/excluded it again, " # e.g {ID: id, id} 290 | "The list of fields which led to this error is %s." 291 | ) % str(repeated_fields) 292 | raise ValidationError(msg, "invalid") 293 | 294 | if excluded_fields: 295 | # Here we are sure that parsed_query.excluded_fields 296 | # is not empty which means the user specified fields to exclude, 297 | # so we just check if provided fields exists then remove them from 298 | # a list of all fields 299 | for field in excluded_fields: 300 | self.is_field_found(field, all_fields, raise_exception=True) 301 | all_fields.pop(field) 302 | 303 | elif included_fields and not include_all_fields: 304 | # Here we are sure that parsed_query.excluded_fields 305 | # is empty which means the exclude operator(-) has not been used, 306 | # so parsed_query.included_fields contains only selected fields 307 | all_allowed_fields = set(allowed_flat_fields) | set( 308 | allowed_nested_fields.keys() 309 | ) 310 | 311 | existing_fields = set(all_fields.keys()) 312 | 313 | non_selected_fields = existing_fields - all_allowed_fields 314 | 315 | for field in non_selected_fields: 316 | # Remove it because we're sure it has not been selected 317 | all_fields.pop(field) 318 | 319 | elif include_all_fields: 320 | # Here we are sure both parsed_query.excluded_fields and 321 | # parsed_query.included_fields are empty, but * has been 322 | # used to select all fields, so we return all fields without 323 | # removing any 324 | pass 325 | 326 | else: 327 | # Otherwise the user specified empty query i.e query={} 328 | # So we return nothing 329 | all_fields = {} 330 | 331 | return all_fields, allowed_nested_fields 332 | 333 | @cached_property 334 | def dynamic_fields(self): 335 | parsed_restql_query = None 336 | 337 | is_root_serializer = self.parent is None or ( 338 | isinstance(self.parent, ListSerializer) and self.parent.parent is None 339 | ) 340 | 341 | if is_root_serializer: 342 | try: 343 | parsed_restql_query = self.get_parsed_restql_query() 344 | except SyntaxError as e: 345 | msg = "QuerySyntaxError: " + e.msg + " on " + e.text 346 | raise ValidationError(msg, code="invalid") from None 347 | except QueryFormatError as e: 348 | msg = "QueryFormatError: " + str(e) 349 | raise ValidationError(msg, code="invalid") from None 350 | 351 | elif isinstance(self.parent, ListSerializer): 352 | field_name = self.parent.field_name 353 | parent = self.parent.parent 354 | if hasattr(parent, "restql_nested_parsed_queries"): 355 | parent_nested_fields = parent.restql_nested_parsed_queries 356 | parsed_restql_query = parent_nested_fields.get(field_name, None) 357 | elif isinstance(self.parent, Serializer): 358 | field_name = self.field_name 359 | parent = self.parent 360 | if hasattr(parent, "restql_nested_parsed_queries"): 361 | parent_nested_fields = parent.restql_nested_parsed_queries 362 | parsed_restql_query = parent_nested_fields.get(field_name, None) 363 | 364 | if parsed_restql_query is None: 365 | # There's no query so we return all fields 366 | return self.allowed_fields 367 | 368 | # Get fields selected by `query` parameter 369 | selected_fields, nested_parsed_queries = self.select_fields( 370 | parsed_query=parsed_restql_query, all_fields=self.allowed_fields 371 | ) 372 | 373 | # Keep track of parsed queries of nested fields 374 | # for future reference from child/nested serializers 375 | self.restql_nested_parsed_queries = nested_parsed_queries 376 | return selected_fields 377 | 378 | def get_parsed_restql_query_from_query_kwarg(self): 379 | parser = QueryParser() 380 | return parser.parse(self.dynamic_fields_mixin_kwargs["query"]) 381 | 382 | def get_parsed_restql_query(self): 383 | request = self.context.get("request") 384 | 385 | if self.dynamic_fields_mixin_kwargs["query"] is not None: 386 | # Get from query kwarg 387 | return self.get_parsed_restql_query_from_query_kwarg() 388 | elif self.dynamic_fields_mixin_kwargs["parsed_query"] is not None: 389 | # Get from parsed_query kwarg 390 | return self.dynamic_fields_mixin_kwargs["parsed_query"] 391 | elif request is not None and self.has_restql_query_param(request): 392 | # Get from request query parameter 393 | return self.get_parsed_restql_query_from_req(request) 394 | return None # There is no query so we return None as a parsed query 395 | 396 | @property 397 | def fields(self): 398 | should_use_dynamic_fields = ( 399 | self.is_ready_to_use_dynamic_fields 400 | and not self.dynamic_fields_mixin_kwargs["disable_dynamic_fields"] 401 | ) 402 | 403 | if should_use_dynamic_fields: 404 | # Return restql fields 405 | return self.dynamic_fields 406 | return self.allowed_fields 407 | 408 | 409 | class EagerLoadingMixin(RequestQueryParserMixin): 410 | @property 411 | def parsed_restql_query(self): 412 | """ 413 | Gets parsed query for use in eager loading. 414 | Defaults to the serializer parsed query. 415 | """ 416 | if self.has_restql_query_param(self.request): 417 | try: 418 | return self.get_parsed_restql_query_from_req(self.request) 419 | except (SyntaxError, QueryFormatError): 420 | # Let `DynamicFieldsMixin` handle this for a user 421 | # to get a helpful error message 422 | pass 423 | 424 | # Else include all fields 425 | query = Query( 426 | field_name=None, 427 | included_fields=["*"], 428 | excluded_fields=[], 429 | aliases={}, 430 | arguments={}, 431 | ) 432 | return query 433 | 434 | @property 435 | def should_auto_apply_eager_loading(self): 436 | if hasattr(self, "auto_apply_eager_loading"): 437 | return self.auto_apply_eager_loading 438 | return restql_settings.AUTO_APPLY_EAGER_LOADING 439 | 440 | def get_select_related_mapping(self): 441 | if hasattr(self, "select_related"): 442 | return self.select_related 443 | # Else select nothing 444 | return {} 445 | 446 | def get_prefetch_related_mapping(self): 447 | if hasattr(self, "prefetch_related"): 448 | return self.prefetch_related 449 | # Else prefetch nothing 450 | return {} 451 | 452 | @classmethod 453 | def get_dict_parsed_restql_query(cls, parsed_restql_query): 454 | """ 455 | Returns the parsed query as a dict. 456 | """ 457 | parsed_query = {} 458 | included_fields = parsed_restql_query.included_fields 459 | excluded_fields = parsed_restql_query.excluded_fields 460 | 461 | for field in included_fields: 462 | if isinstance(field, Query): 463 | nested_keys = cls.get_dict_parsed_restql_query(field) 464 | parsed_query[field.field_name] = nested_keys 465 | else: 466 | parsed_query[field] = True 467 | for field in excluded_fields: 468 | if isinstance(field, Query): 469 | nested_keys = cls.get_dict_parsed_restql_query(field) 470 | parsed_query[field.field_name] = nested_keys 471 | else: 472 | parsed_query[field] = False 473 | return parsed_query 474 | 475 | @staticmethod 476 | def get_related_fields(related_fields_mapping, dict_parsed_restql_query): 477 | """ 478 | Returns only whitelisted related fields from a query to be used on 479 | `select_related` and `prefetch_related` 480 | """ 481 | related_fields = [] 482 | for key, related_field in related_fields_mapping.items(): 483 | fields = key.split(".") 484 | if isinstance(related_field, (str, Prefetch)): 485 | related_field = [related_field] 486 | 487 | query_node = dict_parsed_restql_query 488 | for field in fields: 489 | if isinstance(query_node, dict): 490 | if field in query_node: 491 | # Get a more specific query node 492 | query_node = query_node[field] 493 | elif "*" in query_node: 494 | # All fields are included 495 | continue 496 | else: 497 | # The field is not included in a query so 498 | # don't include this field in `related_fields` 499 | break 500 | else: 501 | # If the loop completed without breaking 502 | if isinstance(query_node, dict) or query_node: 503 | related_fields.extend(related_field) 504 | return related_fields 505 | 506 | def apply_eager_loading(self, queryset): 507 | """ 508 | Applies appropriate select_related and prefetch_related calls on a 509 | queryset 510 | """ 511 | query = self.get_dict_parsed_restql_query(self.parsed_restql_query) 512 | select_mapping = self.get_select_related_mapping() 513 | prefetch_mapping = self.get_prefetch_related_mapping() 514 | 515 | to_select = self.get_related_fields(select_mapping, query) 516 | to_prefetch = self.get_related_fields(prefetch_mapping, query) 517 | 518 | if to_select: 519 | queryset = queryset.select_related(*to_select) 520 | if to_prefetch: 521 | queryset = queryset.prefetch_related(*to_prefetch) 522 | return queryset 523 | 524 | def get_eager_queryset(self, queryset): 525 | return self.apply_eager_loading(queryset) 526 | 527 | def get_queryset(self): 528 | """ 529 | Override for DRF's get_queryset on the view. 530 | If get_queryset is not present, we don't try to run this. 531 | Instead, this can still be used by manually calling 532 | self.get_eager_queryset and passing in the queryset desired. 533 | """ 534 | if hasattr(super(), "get_queryset"): 535 | queryset = super().get_queryset() 536 | if self.should_auto_apply_eager_loading: 537 | queryset = self.get_eager_queryset(queryset) 538 | return queryset 539 | 540 | 541 | class BaseNestedMixin(object): 542 | @staticmethod 543 | def constrain_error_prefix(field): 544 | return "Error on `%s` field: " % (field,) 545 | 546 | def raise_constrain_error(self, error, field=None): 547 | msg = ( 548 | self.constrain_error_prefix(field) if field else "" 549 | ) + str(error) 550 | code = "constrain_error" 551 | raise ValidationError(msg, code=code) from None 552 | 553 | def get_fields(self): 554 | # Replace all temporary fields with the actual fields 555 | fields = super().get_fields() 556 | for field_name, field in fields.items(): 557 | if isinstance(field, TemporaryNestedField): 558 | fields.update( 559 | {field_name: field.get_actual_nested_field(self.__class__)} 560 | ) 561 | return fields 562 | 563 | @cached_property 564 | def restql_writable_nested_fields(self): 565 | # Make field_source -> field_value map for restql nested fields 566 | writable_nested_fields = {} 567 | for _, field in self.fields.items(): 568 | # Get the actual source of the field 569 | if isinstance(field, BaseRESTQLNestedField): 570 | writable_nested_fields.update({field.source: field}) 571 | return writable_nested_fields 572 | 573 | 574 | class NestedCreateMixin(BaseNestedMixin): 575 | """Create Mixin""" 576 | 577 | def create_writable_foreignkey_related(self, data): 578 | # data format 579 | # {field: {sub_field: value}} 580 | objs = {} 581 | nested_fields = self.restql_writable_nested_fields 582 | for field, value in data.items(): 583 | # Get nested field serializer 584 | nested_field_serializer = nested_fields[field] 585 | serializer_class = nested_field_serializer.serializer_class 586 | kwargs = nested_field_serializer.validation_kwargs 587 | serializer = serializer_class( 588 | **kwargs, 589 | data=value, 590 | # Reject partial update by default(if partial kwarg is not passed) 591 | # since we need all required fields when creating object 592 | partial=nested_field_serializer.is_partial(False), 593 | context={**self.context, "parent_operation": CREATE}, 594 | ) 595 | serializer.is_valid(raise_exception=True) 596 | if value is None: 597 | objs.update({field: None}) 598 | else: 599 | obj = serializer.save() 600 | objs.update({field: obj}) 601 | return objs 602 | 603 | def bulk_create_objs(self, field, data): 604 | nested_fields = self.restql_writable_nested_fields 605 | 606 | # Get nested field serializer 607 | nested_field_serializer = nested_fields[field].child 608 | serializer_class = nested_field_serializer.serializer_class 609 | kwargs = nested_field_serializer.validation_kwargs 610 | pks = [] 611 | for values in data: 612 | serializer = serializer_class( 613 | **kwargs, 614 | data=values, 615 | # Reject partial update by default(if partial kwarg is not passed) 616 | # since we need all required fields when creating object 617 | partial=nested_field_serializer.is_partial(False), 618 | context={**self.context, "parent_operation": CREATE}, 619 | ) 620 | serializer.is_valid(raise_exception=True) 621 | obj = serializer.save() 622 | pks.append(obj.pk) 623 | return pks 624 | 625 | def create_many_to_one_related(self, instance, data): 626 | # data format 627 | # {field: { 628 | # ADD: [pks], 629 | # CREATE: [{sub_field: value}] 630 | # }...} 631 | for field, values in data.items(): 632 | model = self.Meta.model 633 | foreignkey = getattr(model, field).field.name 634 | nested_fields = self.restql_writable_nested_fields 635 | try: 636 | for operation in values: 637 | if operation == ADD: 638 | pks = values[operation] 639 | model = nested_fields[field].child.Meta.model 640 | qs = model.objects.filter(pk__in=pks) 641 | qs.update(**{foreignkey: instance.pk}) 642 | elif operation == CREATE: 643 | for v in values[operation]: 644 | v.update({foreignkey: instance.pk}) 645 | self.bulk_create_objs(field, values[operation]) 646 | except Exception as e: 647 | self.raise_constrain_error(e, field=field) 648 | 649 | def create_many_to_one_generic_related(self, instance, data): 650 | # data format 651 | # {field: { 652 | # ADD: [pks], 653 | # CREATE: [{sub_field: value}] 654 | # }...} 655 | if not data: 656 | return 657 | 658 | nested_fields = self.restql_writable_nested_fields 659 | 660 | content_type = ( 661 | ContentType.objects.get_for_model(instance) if ContentType else None 662 | ) 663 | for field, values in data.items(): 664 | relation = getattr(self.Meta.model, field).field 665 | nested_field_serializer = nested_fields[field].child 666 | serializer_class = nested_field_serializer.serializer_class 667 | kwargs = nested_field_serializer.validation_kwargs 668 | model = nested_field_serializer.Meta.model 669 | try: 670 | for operation in values: 671 | if operation == ADD: 672 | pks = values[operation] 673 | qs = model.objects.filter(pk__in=pks) 674 | qs.update( 675 | **{ 676 | relation.object_id_field_name: instance.pk, 677 | relation.content_type_field_name: content_type, 678 | } 679 | ) 680 | elif operation == CREATE: 681 | serializer = serializer_class( 682 | data=values[operation], **kwargs, many=True, 683 | # Reject partial update by default(if partial kwarg is not passed) 684 | # since we need all required fields when creating object 685 | partial=nested_field_serializer.is_partial(False), 686 | context={**self.context, "parent_operation": CREATE}, 687 | ) 688 | serializer.is_valid(raise_exception=True) 689 | items = serializer.validated_data 690 | 691 | objs = [ 692 | model( 693 | **item, 694 | **{ 695 | relation.content_type_field_name: content_type, 696 | relation.object_id_field_name: instance.pk, 697 | }, 698 | ) 699 | for item in items 700 | ] 701 | model.objects.bulk_create(objs) 702 | except Exception as e: 703 | self.raise_constrain_error(e, field=field) 704 | 705 | def create_many_to_many_related(self, instance, data): 706 | # data format 707 | # {field: { 708 | # ADD: [pks], 709 | # CREATE: [{sub_field: value}] 710 | # }...} 711 | for field, values in data.items(): 712 | obj = getattr(instance, field) 713 | try: 714 | for operation in values: 715 | if operation == ADD: 716 | pks = values[operation] 717 | obj.add(*pks) 718 | elif operation == CREATE: 719 | pks = self.bulk_create_objs(field, values[operation]) 720 | obj.add(*pks) 721 | except Exception as e: 722 | self.raise_constrain_error(e, field=field) 723 | 724 | def create(self, validated_data): 725 | # Make a copy of validated_data so that we don't 726 | # alter it in case user need to access it later 727 | validated_data_copy = {**validated_data} 728 | 729 | fields = { 730 | "foreignkey_related": { 731 | "writable": {}, 732 | "replaceable": {} 733 | }, 734 | "many_to": { 735 | "one_related": {}, 736 | "many_related": {}, 737 | "one_generic_related": {} 738 | } 739 | } 740 | 741 | restql_nested_fields = self.restql_writable_nested_fields 742 | for field in restql_nested_fields: 743 | if field not in validated_data_copy: 744 | # Nested field value is not provided 745 | continue 746 | 747 | field_serializer = restql_nested_fields[field] 748 | 749 | if isinstance(field_serializer, Serializer): 750 | if field_serializer.is_replaceable: 751 | value = validated_data_copy.pop(field) 752 | fields["foreignkey_related"]["replaceable"].update({field: value}) 753 | else: 754 | value = validated_data_copy.pop(field) 755 | fields["foreignkey_related"]["writable"].update({field: value}) 756 | elif isinstance(field_serializer, ListSerializer): 757 | model = self.Meta.model 758 | rel = getattr(model, field).rel 759 | 760 | if isinstance(rel, ManyToOneRel): 761 | value = validated_data_copy.pop(field) 762 | fields["many_to"]["one_related"].update({field: value}) 763 | elif isinstance(rel, ManyToManyRel): 764 | value = validated_data_copy.pop(field) 765 | fields["many_to"]["many_related"].update({field: value}) 766 | elif GenericRel and isinstance(rel, GenericRel): 767 | value = validated_data_copy.pop(field) 768 | fields["many_to"]["one_generic_related"].update({field: value}) 769 | 770 | foreignkey_related = { 771 | **fields["foreignkey_related"]["replaceable"], 772 | **self.create_writable_foreignkey_related( 773 | fields["foreignkey_related"]["writable"] 774 | ), 775 | } 776 | 777 | instance = super().create({**validated_data_copy, **foreignkey_related}) 778 | 779 | self.create_many_to_one_related( 780 | instance, fields["many_to"]["one_related"] 781 | ) 782 | self.create_many_to_many_related( 783 | instance, fields["many_to"]["many_related"] 784 | ) 785 | self.create_many_to_one_generic_related( 786 | instance, fields["many_to"]["one_generic_related"] 787 | ) 788 | 789 | return instance 790 | 791 | 792 | class NestedUpdateMixin(BaseNestedMixin): 793 | """Update Mixin""" 794 | 795 | def update_replaceable_foreignkey_related(self, instance, data): 796 | # data format {field: obj} 797 | for field, nested_obj in data.items(): 798 | setattr(instance, field, nested_obj) 799 | if data: 800 | try: 801 | instance.save() 802 | except Exception as e: 803 | self.raise_constrain_error(e) 804 | 805 | def update_writable_foreignkey_related(self, instance, data): 806 | # data format {field: {sub_field: value}} 807 | nested_fields = self.restql_writable_nested_fields 808 | 809 | needs_save = False 810 | for field, values in data.items(): 811 | # Get nested field serializer 812 | nested_field_serializer = nested_fields[field] 813 | serializer_class = nested_field_serializer.serializer_class 814 | kwargs = nested_field_serializer.validation_kwargs 815 | nested_obj = getattr(instance, field) 816 | serializer = serializer_class( 817 | nested_obj, 818 | **kwargs, 819 | data=values, 820 | # Allow partial update by default(if partial kwarg is not passed) 821 | # since this is nested update 822 | partial=nested_field_serializer.is_partial(True), 823 | context={**self.context, "parent_operation": UPDATE}, 824 | ) 825 | serializer.is_valid(raise_exception=True) 826 | 827 | delete_previous_nested_obj = False 828 | if values is None: 829 | setattr(instance, field, None) 830 | needs_save = True 831 | if nested_field_serializer.should_delete_on_null and nested_obj is not None: 832 | delete_previous_nested_obj = True 833 | else: 834 | obj = serializer.save() 835 | if nested_obj is None: 836 | # Patch back newly created object to instance 837 | setattr(instance, field, obj) 838 | needs_save = True 839 | if needs_save: 840 | try: 841 | instance.save() 842 | 843 | # We delete the previous nested obj AFTER we are done saving 844 | # the instance to avoid accidental deletion of the instance 845 | # itself if on_delete=models.CASCADE is used 846 | if delete_previous_nested_obj: 847 | nested_obj.delete() 848 | except Exception as e: 849 | self.raise_constrain_error(e) 850 | 851 | def bulk_create_many_to_many_related(self, field, nested_obj, data): 852 | # data format [{field1: value1....}, ...] 853 | 854 | # Get nested field serializer 855 | nested_field_serializer = self.restql_writable_nested_fields[field].child 856 | serializer_class = nested_field_serializer.serializer_class 857 | kwargs = nested_field_serializer.validation_kwargs 858 | pks = [] 859 | for values in data: 860 | serializer = serializer_class( 861 | **kwargs, 862 | data=values, 863 | # Reject partial update by default(if partial kwarg is not passed) 864 | # since we need all required fields when creating object 865 | partial=nested_field_serializer.is_partial(False), 866 | context={**self.context, "parent_operation": CREATE}, 867 | ) 868 | serializer.is_valid(raise_exception=True) 869 | obj = serializer.save() 870 | pks.append(obj.pk) 871 | nested_obj.add(*pks) 872 | 873 | def bulk_create_many_to_one_related(self, field, data): 874 | # data format [{field1: value1....}, ...] 875 | 876 | # Get nested field serializer 877 | nested_field_serializer = self.restql_writable_nested_fields[field].child 878 | serializer_class = nested_field_serializer.serializer_class 879 | kwargs = nested_field_serializer.validation_kwargs 880 | 881 | for values in data: 882 | serializer = serializer_class( 883 | **kwargs, 884 | data=values, 885 | # Reject partial update by default(if partial kwarg is not passed) 886 | # since we need all required fields when creating object 887 | partial=nested_field_serializer.is_partial(False), 888 | context={**self.context, "parent_operation": CREATE}, 889 | ) 890 | serializer.is_valid(raise_exception=True) 891 | serializer.save() 892 | 893 | def bulk_update_many_to_many_related(self, field, nested_obj, data): 894 | # {pk: {sub_field: values}} 895 | 896 | # Get nested field serializer 897 | nested_field_serializer = self.restql_writable_nested_fields[field].child 898 | serializer_class = nested_field_serializer.serializer_class 899 | kwargs = nested_field_serializer.validation_kwargs 900 | for pk, values in data.items(): 901 | try: 902 | obj = nested_obj.get(pk=pk) 903 | except ObjectDoesNotExist: 904 | # This pk does't belong to nested field 905 | continue 906 | serializer = serializer_class( 907 | obj, 908 | **kwargs, 909 | data=values, 910 | # Allow partial update by default(if partial kwarg is not passed) 911 | # since this is nested update 912 | partial=nested_field_serializer.is_partial(True), 913 | context={**self.context, "parent_operation": UPDATE}, 914 | ) 915 | serializer.is_valid(raise_exception=True) 916 | serializer.save() 917 | 918 | def bulk_update_many_to_one_related( 919 | self, field, instance, data, update_foreign_key=True 920 | ): 921 | # {pk: {sub_field: values}} 922 | 923 | # Get nested field serializer 924 | nested_field_serializer = self.restql_writable_nested_fields[field].child 925 | serializer_class = nested_field_serializer.serializer_class 926 | kwargs = nested_field_serializer.validation_kwargs 927 | model = self.Meta.model 928 | foreignkey = getattr(model, field).field.name 929 | nested_obj = getattr(instance, field) 930 | for pk, values in data.items(): 931 | try: 932 | obj = nested_obj.get(pk=pk) 933 | except ObjectDoesNotExist: 934 | # This pk does't belong to nested field 935 | continue 936 | if update_foreign_key: 937 | values.update({foreignkey: instance.pk}) 938 | serializer = serializer_class( 939 | obj, 940 | **kwargs, 941 | data=values, 942 | # Allow partial update by default(if partial kwarg is not passed) 943 | # since this is nested update 944 | partial=nested_field_serializer.is_partial(True), 945 | context={**self.context, "parent_operation": UPDATE}, 946 | ) 947 | serializer.is_valid(raise_exception=True) 948 | serializer.save() 949 | 950 | def update_many_to_one_related(self, instance, data): 951 | # data format 952 | # {field: { 953 | # ADD: [{sub_field: value}], 954 | # CREATE: [{sub_field: value}], 955 | # REMOVE: [pk], 956 | # DELETE: [pk], 957 | # UPDATE: {pk: {sub_field: value}} 958 | # }...} 959 | for field, values in data.items(): 960 | nested_obj = getattr(instance, field) 961 | model = self.Meta.model 962 | foreignkey = getattr(model, field).field.name 963 | nested_fields = self.restql_writable_nested_fields 964 | try: 965 | for operation in values: 966 | if operation == ADD: 967 | pks = values[operation] 968 | model = nested_fields[field].child.Meta.model 969 | qs = model.objects.filter(pk__in=pks) 970 | qs.update(**{foreignkey: instance.pk}) 971 | elif operation == CREATE: 972 | for v in values[operation]: 973 | v.update({foreignkey: instance.pk}) 974 | self.bulk_create_many_to_one_related( 975 | field, values[operation] 976 | ) 977 | elif operation == REMOVE: 978 | reverse_name = nested_obj.field.name 979 | qs = nested_obj.all() 980 | 981 | if values[operation] == ALL_RELATED_OBJS: 982 | qs.update(**{reverse_name: None}) 983 | else: 984 | qs.filter(pk__in=values[operation]).update(**{reverse_name: None}) 985 | elif operation == DELETE: 986 | qs = nested_obj.all() 987 | if values[operation] == ALL_RELATED_OBJS: 988 | qs.delete() 989 | else: 990 | qs.filter(pk__in=values[operation]).delete() 991 | elif operation == UPDATE: 992 | self.bulk_update_many_to_one_related( 993 | field, instance, values[operation] 994 | ) 995 | except Exception as e: 996 | self.raise_constrain_error(e, field=field) 997 | 998 | def update_many_to_one_generic_related(self, instance, data): 999 | # data format 1000 | # {field: { 1001 | # ADD: [{sub_field: value}], 1002 | # CREATE: [{sub_field: value}], 1003 | # REMOVE: [pk], 1004 | # DELETE: [pk], 1005 | # UPDATE: {pk: {sub_field: value}} 1006 | # }...} 1007 | if not data: 1008 | return 1009 | 1010 | content_type = ( 1011 | ContentType.objects.get_for_model(instance) if ContentType else None 1012 | ) 1013 | 1014 | for field, values in data.items(): 1015 | nested_obj = getattr(instance, field) 1016 | relation = getattr(self.Meta.model, field).field 1017 | nested_fields = self.restql_writable_nested_fields 1018 | nested_field_serializer = nested_fields[field].child 1019 | serializer_class = nested_field_serializer.serializer_class 1020 | kwargs = nested_field_serializer.validation_kwargs 1021 | model = nested_field_serializer.Meta.model 1022 | try: 1023 | for operation in values: 1024 | if operation == ADD: 1025 | pks = values[operation] 1026 | qs = model.objects.filter(pk__in=pks) 1027 | qs.update( 1028 | **{ 1029 | relation.object_id_field_name: instance.pk, 1030 | relation.content_type_field_name: content_type, 1031 | } 1032 | ) 1033 | elif operation == CREATE: 1034 | serializer = serializer_class( 1035 | data=values[operation], **kwargs, many=True, 1036 | # Reject partial update by default(if partial kwarg is not passed) 1037 | # since we need all required fields when creating object 1038 | partial=nested_field_serializer.is_partial(False), 1039 | context={**self.context, "parent_operation": CREATE}, 1040 | ) 1041 | serializer.is_valid(raise_exception=True) 1042 | items = serializer.validated_data 1043 | 1044 | objs = [ 1045 | model( 1046 | **item, 1047 | **{ 1048 | relation.content_type_field_name: content_type, 1049 | relation.object_id_field_name: instance.pk, 1050 | }, 1051 | ) 1052 | for item in items 1053 | ] 1054 | model.objects.bulk_create(objs) 1055 | elif operation == REMOVE: 1056 | qs = nested_obj.all() 1057 | if values[operation] == ALL_RELATED_OBJS: 1058 | qs.update(**{ 1059 | nested_obj.object_id_field_name: None, 1060 | nested_obj.content_type_field_name: None 1061 | }) 1062 | else: 1063 | qs.filter(pk__in=values[operation]).update(**{ 1064 | nested_obj.object_id_field_name: None, 1065 | nested_obj.content_type_field_name: None 1066 | }) 1067 | elif operation == DELETE: 1068 | qs = nested_obj.all() 1069 | if values[operation] == ALL_RELATED_OBJS: 1070 | qs.delete() 1071 | else: 1072 | qs.filter(pk__in=values[operation]).delete() 1073 | elif operation == UPDATE: 1074 | self.bulk_update_many_to_one_related( 1075 | field, instance, values[operation], update_foreign_key=False 1076 | ) 1077 | except Exception as e: 1078 | self.raise_constrain_error(e, field=field) 1079 | 1080 | def update_many_to_many_related(self, instance, data): 1081 | # data format 1082 | # {field: { 1083 | # ADD: [{sub_field: value}], 1084 | # CREATE: [{sub_field: value}], 1085 | # REMOVE: [pk], 1086 | # DELETE: [pk], 1087 | # UPDATE: {pk: {sub_field: value}} 1088 | # }...} 1089 | for field, values in data.items(): 1090 | nested_obj = getattr(instance, field) 1091 | try: 1092 | for operation in values: 1093 | if operation == ADD: 1094 | pks = values[operation] 1095 | nested_obj.add(*pks) 1096 | elif operation == CREATE: 1097 | self.bulk_create_many_to_many_related( 1098 | field, nested_obj, values[operation] 1099 | ) 1100 | elif operation == REMOVE: 1101 | pks = values[operation] 1102 | if pks == ALL_RELATED_OBJS: 1103 | pks = nested_obj.all() 1104 | nested_obj.remove(*pks) 1105 | elif operation == DELETE: 1106 | qs = nested_obj.all() 1107 | if values[operation] == ALL_RELATED_OBJS: 1108 | qs.delete() 1109 | else: 1110 | qs.filter(pk__in=values[operation]).delete() 1111 | elif operation == UPDATE: 1112 | self.bulk_update_many_to_many_related( 1113 | field, nested_obj, values[operation] 1114 | ) 1115 | except Exception as e: 1116 | self.raise_constrain_error(e, field=field) 1117 | 1118 | def update(self, instance, validated_data): 1119 | # Make a copy of validated_data so that we don't 1120 | # alter it in case user need to access it later 1121 | validated_data_copy = {**validated_data} 1122 | 1123 | fields = { 1124 | "foreignkey_related": { 1125 | "writable": {}, 1126 | "replaceable": {} 1127 | }, 1128 | "many_to": { 1129 | "one_related": {}, 1130 | "many_related": {}, 1131 | "one_generic_related": {} 1132 | } 1133 | } 1134 | 1135 | restql_nested_fields = self.restql_writable_nested_fields 1136 | for field in restql_nested_fields: 1137 | if field not in validated_data_copy: 1138 | # Nested field value is not provided 1139 | continue 1140 | 1141 | field_serializer = restql_nested_fields[field] 1142 | 1143 | if isinstance(field_serializer, Serializer): 1144 | if field_serializer.is_replaceable: 1145 | value = validated_data_copy.pop(field) 1146 | fields["foreignkey_related"]["replaceable"].update({field: value}) 1147 | else: 1148 | value = validated_data_copy.pop(field) 1149 | fields["foreignkey_related"]["writable"].update({field: value}) 1150 | elif isinstance(field_serializer, ListSerializer): 1151 | model = self.Meta.model 1152 | rel = getattr(model, field).rel 1153 | 1154 | if isinstance(rel, ManyToOneRel): 1155 | value = validated_data_copy.pop(field) 1156 | fields["many_to"]["one_related"].update({field: value}) 1157 | elif isinstance(rel, ManyToManyRel): 1158 | value = validated_data_copy.pop(field) 1159 | fields["many_to"]["many_related"].update({field: value}) 1160 | elif GenericRel and isinstance(rel, GenericRel): 1161 | value = validated_data_copy.pop(field) 1162 | fields["many_to"]["one_generic_related"].update({field: value}) 1163 | 1164 | instance = super().update(instance, validated_data_copy) 1165 | 1166 | self.update_replaceable_foreignkey_related( 1167 | instance, fields["foreignkey_related"]["replaceable"] 1168 | ) 1169 | self.update_writable_foreignkey_related( 1170 | instance, fields["foreignkey_related"]["writable"] 1171 | ) 1172 | self.update_many_to_one_related( 1173 | instance, fields["many_to"]["one_related"] 1174 | ) 1175 | self.update_many_to_many_related( 1176 | instance, fields["many_to"]["many_related"] 1177 | ) 1178 | self.update_many_to_one_generic_related( 1179 | instance, fields["many_to"]["one_generic_related"] 1180 | ) 1181 | 1182 | return instance 1183 | --------------------------------------------------------------------------------