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

More examples to get you comfortable with the query syntax

98 | `GET /users/?query={location, groups}` 99 | ```js 100 | [ 101 | { 102 | "location": { 103 | "id": 1, 104 | "contry": "Tanzania", 105 | "city": "Dar es salaam", 106 | "street": "Oyster Bay" 107 | } 108 | "groups": [ 109 | {"id": 2, "name": "Auth_User"}, 110 | {"id": 3, "name": "Admin_User"} 111 | ] 112 | }, 113 | ... 114 | ] 115 | ``` 116 |
117 | 118 | `GET /users/?query={id, username, groups{name}}` 119 | ```js 120 | [ 121 | { 122 | "id": 1, 123 | "username": "yezyilomo", 124 | "groups": [ 125 | {"name": "Auth_User"}, 126 | {"name": "Admin_User"} 127 | ] 128 | }, 129 | ... 130 | ] 131 | ``` 132 | 133 | !!! note 134 | Using commas(`,`) to separate fields and arguments is optional, you can use spaces too just like in GraphQL 135 | For example you could write your query as ```query={id username location{country city}}``` so the choice is yours. 136 | 137 | ## Exclude(-) operator 138 | 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. 139 | 140 | **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. Take an example below 141 | 142 | ```py 143 | from rest_framework import serializers 144 | from django_restql.mixins import DynamicFieldsMixin 145 | 146 | from app.models import Location, Property 147 | 148 | 149 | class LocationSerializer(DynamicFieldsMixin, serializer.ModelSerializer): 150 | class Meta: 151 | model = Location 152 | fields = ["id", "city", "country", "state", "street"] 153 | 154 | 155 | class PropertySerializer(DynamicFieldsMixin, serializer.ModelSerializer): 156 | location = LocationSerializer(many=False, read_only=True) 157 | class Meta: 158 | model = Property 159 | fields = [ 160 | 'id', 'price', 'location' 161 | ] 162 | ``` 163 | 164 | If we want to get all fields under `LocationSerializer` except `id` and `street`, by using the exclude(-) operator we could do it as follows 165 | 166 | `GET /location/?query={-id, -street}` 167 | ```js 168 | [ 169 | { 170 | "country": "China", 171 | "city": "Beijing", 172 | "state": "Chaoyang" 173 | }, 174 | ... 175 | ] 176 | ``` 177 | This is equivalent to `query={country, city, state}` 178 | 179 | 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. 180 | 181 | `GET /property/?query={price, location{-id}}` 182 | ```js 183 | [ 184 | { 185 | "price": 5000 186 | "location": { 187 | "country": "China", 188 | "city" "Beijing", 189 | "state": "Chaoyang", 190 | "street": "Hanang" 191 | } 192 | }, 193 | ... 194 | ] 195 | ``` 196 | This is equivalent to `query={price, location{country, city, state, street}}` 197 | 198 |

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

199 | Assuming this is the structure of the model we are querying 200 | ```py 201 | data = { 202 | username, 203 | birthdate, 204 | location { 205 | country, 206 | city 207 | }, 208 | contact { 209 | phone, 210 | email 211 | } 212 | } 213 | ``` 214 | 215 | Here is how we can structure our queries to exclude some fields by using exclude(-) operator 216 | ```py 217 | {-username} ≡ {birthdate, location{country, city}, contact{phone, email}} 218 | 219 | {-username, contact{phone}, location{country}} ≡ {birthdate ,contact{phone}, location{country}} 220 | 221 | {-contact, location{country}} ≡ {username, birthdate, location{country}} 222 | 223 | {-contact, -location} ≡ {username, birthdate} 224 | 225 | {username, location{-country}} ≡ {username, location{city}} 226 | 227 | {username, location{-city}, contact{-email}} ≡ {username, location{country}, contact{phone}} 228 | ``` 229 | 230 | ## Wildcard(*) operator 231 | 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 232 | 233 | `query={*}` 234 | 235 | 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 236 | 237 | ```py 238 | user = { 239 | username, 240 | birthdate, 241 | contact { 242 | phone, 243 | email, 244 | twitter, 245 | github, 246 | linkedin, 247 | facebook 248 | } 249 | } 250 | ``` 251 | 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 252 | 253 | `query={username, birthdate, contact{phone}}` 254 | 255 | 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 256 | 257 | `query={*, contact{phone}}` 258 | 259 | 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. 260 | 261 |

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

262 | ```py 263 | {*, -username, contact{phone}} ≡ {birthdate, contact{phone}} 264 | 265 | {username, contact{*, -facebook, -linkedin}} ≡ {username, contact{phone, email, twitter, github}} 266 | 267 | {*, -username, contact{*, -facebook, -linkedin}} ≡ {birthdate, contact{phone, email, twitter, github}} 268 | ``` 269 | 270 | 271 | 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. 272 | ```py 273 | {username, -location{country}} # Should not expand excluded field 274 | {*username} # What are you even trying to accomplish 275 | {*location{country}} # This is definitely wrong 276 | ``` 277 | 278 | 279 | ## Aliases 280 | When working with API, you may want to rename a field to something other than what the API has to offer. Aliases exist as part of this library to solve this exact problem. 281 | 282 | Aliases allow you to rename a single field to whatever you want it to be. They are defined at the client side, so you don’t need to update your API to use them. 283 | 284 | Imagine requesting data using the following query from an API: 285 | 286 | `GET /users/?query={id, updated_at}` 287 | 288 | You will get the following JSON response: 289 | 290 | ```js 291 | [ 292 | { 293 | "id": 1, 294 | "updated_at": "2021-05-05T21:05:23.034Z" 295 | }, 296 | ... 297 | ] 298 | ``` 299 | 300 | The id here is fine, but the `updated_at` doesn’t quite conform to the camel case convention in JavaScript(Which is where APIs are used mostly). Let’s change it by using an alias. 301 | 302 | `GET /users/?query={id, updatedAt: updated_at}` 303 | 304 | Which yields the following: 305 | 306 | ```js 307 | [ 308 | { 309 | "id": 1, 310 | "updatedAt": "2021-05-05T21:05:23.034Z" 311 | }, 312 | ... 313 | ] 314 | ``` 315 | 316 | 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. 317 | 318 |

More examples

319 | 320 | Renaming `date_of_birth` to `dateOfBirth`, `course` to `programme` and `books` to `readings` 321 | 322 | `GET /students/?query={name, dateOfBirth: date_of_birth, programme: course{id, name, readings: books}}` 323 | 324 | This yields 325 | 326 | ```js 327 | [ 328 | { 329 | "name": "Yezy Ilomo", 330 | "dateOfBirth": "04-08-1995", 331 | "programme": { 332 | "id": 4, 333 | "name": "Computer Science", 334 | "readings": [ 335 | {"id": 1, "title": "Alogarithms"}, 336 | {"id": 2, "title": "Data Structures"}, 337 | ] 338 | } 339 | }, 340 | ... 341 | ] 342 | ``` 343 | 344 | !!! note 345 | The default maximum length of alias 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 clients specifying a really really long alias which may increase network usage. For more information about `MAX_ALIAS_LEN` setting and how to change it go to [this section](/django-restql/settings/#max_alias_len). 346 | 347 | 348 | ## DynamicSerializerMethodField 349 | `DynamicSerializerMethodField` is a wraper of the `SerializerMethodField`, it adds a parsed query argument from a parent serializer to a method bound to a `SerializerMethodField`, this parsed query argument can be passed to a serializer used within a method to allow further querying. For example in the scenario below we are using `DynamicSerializerMethodField` because we want to be able to query `related_books` field. 350 | 351 | ```py 352 | from django_restql.mixins import DynamicFieldsMixin 353 | from django_restql.fields import DynamicSerializerMethodField 354 | 355 | 356 | class CourseSerializer(DynamicFieldsMixin, serializers.ModelSerializer): 357 | # Use `DynamicSerializerMethodField` instead of `SerializerMethodField` 358 | # if you want to be able to query `related_books` 359 | related_books = DynamicSerializerMethodField() 360 | class Meta: 361 | model = Course 362 | fields = ['name', 'code', 'related_books'] 363 | 364 | def get_related_books(self, obj, parsed_query): 365 | # With `DynamicSerializerMethodField` you get this extra 366 | # `parsed_query` argument in addition to `obj` 367 | books = obj.books.all() 368 | 369 | # You can do what ever you want in here 370 | 371 | # `parsed_query` param is passed to BookSerializer to allow further querying 372 | serializer = BookSerializer( 373 | books, 374 | many=True, 375 | parsed_query=parsed_query 376 | ) 377 | return serializer.data 378 | ``` 379 | 380 | `GET /course/?query={name, related_books}` 381 | ```js 382 | [ 383 | { 384 | "name": "Data Structures", 385 | "related_books": [ 386 | {"title": "Advanced Data Structures", "author": "S.Mobit"}, 387 | {"title": "Basic Data Structures", "author": "S.Mobit"} 388 | ] 389 | } 390 | ] 391 | ``` 392 |
393 | 394 | `GET /course/?query={name, related_books{title}}` 395 | ```js 396 | [ 397 | { 398 | "name": "Data Structures", 399 | "related_books": [ 400 | {"title": "Advanced Data Structures"}, 401 | {"title": "Basic Data Structures"} 402 | ] 403 | } 404 | ] 405 | ``` 406 |
407 | 408 | ## DynamicFieldsMixin kwargs 409 | `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. 410 | 411 | ### fields kwarg 412 | 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. 413 | 414 | ```py 415 | from rest_framework import serializers 416 | from django.contrib.auth.models import User 417 | from django_restql.mixins import DynamicFieldsMixin 418 | 419 | from app.models import Book, Course 420 | 421 | 422 | class BookSerializer(DynamicFieldsMixin, serializers.ModelSerializer): 423 | class Meta: 424 | model = Book 425 | fields = ['id', 'title', 'author'] 426 | 427 | 428 | class CourseSerializer(DynamicFieldsMixin, serializers.ModelSerializer): 429 | books = BookSerializer(many=True, read_only=True, fields=["title"]) 430 | class Meta: 431 | model = Course 432 | fields = ['name', 'code', 'books'] 433 | ``` 434 | 435 | `GET /courses/` 436 | ```js 437 | [ 438 | { 439 | "name": "Computer Programming", 440 | "code": "CS50", 441 | "books": [ 442 | {"title": "Computer Programming Basics"}, 443 | {"title": "Data structures"} 444 | ] 445 | }, 446 | ... 447 | ] 448 | ``` 449 | 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 450 | 451 | `GET /course?query={name, code, books{title, author}}` 452 | 453 | you will get an error that `author` field is not found because it was not included here `fields=["title"]`. 454 | 455 | 456 | ### exclude kwarg 457 | 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. 458 | ```py 459 | from rest_framework import serializers 460 | from django_restql.mixins import DynamicFieldsMixin 461 | 462 | from app.models import Book, Course 463 | 464 | 465 | class BookSerializer(DynamicFieldsMixin, serializers.ModelSerializer): 466 | class Meta: 467 | model = Book 468 | fields = ['id', 'title', 'author'] 469 | 470 | 471 | class CourseSerializer(DynamicFieldsMixin, serializers.ModelSerializer): 472 | books = BookSerializer(many=True, read_only=True, exclude=["author"]) 473 | class Meta: 474 | model = Course 475 | fields = ['name', 'code', 'books'] 476 | ``` 477 | 478 | `GET /courses/` 479 | ```js 480 | [ 481 | { 482 | "name": "Computer Programming", 483 | "code": "CS50", 484 | "books": [ 485 | {"id": 1, "title": "Computer Programming Basics"}, 486 | {"id": 2, "title": "Data structures"} 487 | ] 488 | }, 489 | ... 490 | ] 491 | ``` 492 | 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. 493 | 494 | !!! note 495 | `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 496 | 497 | `GET /books/` 498 | ```js 499 | [ 500 | { 501 | "id": 1, 502 | "title": "Computer Programming Basics", 503 | "author": "S.Mobit" 504 | }, 505 | ... 506 | ] 507 | ``` 508 | So you can see that all fields have appeared as specified on `fields = ['id', 'title', 'author']` on BookSerializer class. 509 |
510 | 511 | 512 | ### query kwarg 513 | **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 514 | 515 | ```py 516 | from rest_framework import serializers 517 | from django_restql.mixins import DynamicFieldsMixin 518 | 519 | from app.models import Book, Course 520 | 521 | 522 | class BookSerializer(DynamicFieldsMixin, serializers.ModelSerializer): 523 | class Meta: 524 | model = Book 525 | fields = ['id', 'title', 'author'] 526 | 527 | 528 | class CourseSerializer(DynamicFieldsMixin, serializers.ModelSerializer): 529 | books = BookSerializer(many=True, read_only=True, exclude=["author"]) 530 | class Meta: 531 | model = Course 532 | fields = ['name', 'code', 'books'] 533 | ``` 534 | 535 | You can query fields as 536 | 537 | ```py 538 | objs = Course.objects.all() 539 | query = "{name, books{title}}" 540 | serializer = CourseSerializer(objs, many=True, query=query) 541 | print(serializer.data) 542 | 543 | # This will print 544 | [ 545 | { 546 | "name": "Computer Programming", 547 | "books": [ 548 | {"title": "Computer Programming Basics"}, 549 | {"title": "Data structures"} 550 | ] 551 | }, 552 | ... 553 | ] 554 | ``` 555 | 556 | 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 to a `query` kwarg. 557 | 558 | 559 | ### parsed_query kwarg 560 | 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 561 | 562 | ```py 563 | from rest_framework import serializers 564 | from django_restql.mixins import DynamicFieldsMixin 565 | 566 | from app.models import Book, Course 567 | 568 | 569 | class BookSerializer(DynamicFieldsMixin, serializers.ModelSerializer): 570 | class Meta: 571 | model = Book 572 | fields = ['id', 'title', 'author'] 573 | 574 | 575 | class CourseSerializer(DynamicFieldsMixin, serializers.ModelSerializer): 576 | books = BookSerializer(many=True, read_only=True, exclude=["author"]) 577 | class Meta: 578 | model = Course 579 | fields = ['name', 'code', 'books'] 580 | ``` 581 | 582 | You can query fields by using `parsed_query` kwarg as follows 583 | 584 | ```py 585 | import QueryParser from django_restql.parser 586 | 587 | objs = Course.objects.all() 588 | query = "{name, books{title}}" 589 | 590 | # You have to parse your query string first 591 | parser = QueryParser() 592 | parsed_query = parser.parse(query) 593 | 594 | serializer = CourseSerializer(objs, many=True, parsed_query=parsed_query) 595 | print(serializer.data) 596 | 597 | # This will print 598 | [ 599 | { 600 | "name": "Computer Programming", 601 | "books": [ 602 | {"title": "Computer Programming Basics"}, 603 | {"title": "Data structures"} 604 | ] 605 | }, 606 | ... 607 | ] 608 | ``` 609 | 610 | `parsed_query` kwarg is often used with `DynamicMethodField` to pass part of parsed query to nested fields to allow further querying. 611 | 612 | 613 | ### return_pk kwarg 614 | With **Django RESTQL** you can specify whether to return nested resource pk or data. Below is an example which shows how we can use `return_pk` kwarg. 615 | 616 | ```py 617 | from rest_framework import serializers 618 | from django_restql.mixins import DynamicFieldsMixin 619 | 620 | from app.models import Book, Course 621 | 622 | 623 | class BookSerializer(DynamicFieldsMixin, serializers.ModelSerializer): 624 | class Meta: 625 | model = Book 626 | fields = ['id', 'title', 'author'] 627 | 628 | 629 | class CourseSerializer(DynamicFieldsMixin, serializers.ModelSerializer): 630 | books = BookSerializer(many=True, read_only=True, return_pk=True) 631 | class Meta: 632 | model = Course 633 | fields = ['name', 'code', 'books'] 634 | ``` 635 | 636 | `GET /course/` 637 | ```js 638 | [ 639 | { 640 | "name": "Computer Programming", 641 | "code": "CS50", 642 | "books": [1, 2] 643 | }, 644 | ... 645 | ] 646 | ``` 647 | So you can see that on a nested field `books` pks have been returned instead of books data as specified on `return_pk=True` kwarg on `BookSerializer`. 648 |
649 | 650 | 651 | ### disable_dynamic_fields kwarg 652 | Sometimes there are cases where you want to disable fields filtering with 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. 653 | 654 | ```py 655 | from rest_framework import serializers 656 | from django_restql.mixins import DynamicFieldsMixin 657 | 658 | from app.models import Book, Course 659 | 660 | 661 | class BookSerializer(DynamicFieldsMixin, serializers.ModelSerializer): 662 | class Meta: 663 | model = Book 664 | fields = ['id', 'title', 'author'] 665 | 666 | 667 | class CourseSerializer(DynamicFieldsMixin, serializers.ModelSerializer): 668 | # Disable fields filtering on this field 669 | books = BookSerializer(many=True, read_only=True, disable_dynamic_fields=True) 670 | class Meta: 671 | model = Course 672 | fields = ['name', 'code', 'books'] 673 | ``` 674 | 675 | `GET /course/?query={name, books{title}}` 676 | ```js 677 | [ 678 | { 679 | "name": "Computer Programming", 680 | "books": [ 681 | {"id": 1, "title": "Computer Programming Basics", "author": "J.Vough"}, 682 | {"id": 2, "title": "Data structures", "author": "D.Denis"} 683 | ] 684 | }, 685 | ... 686 | ] 687 | ``` 688 | So you can see that even though the query asked for only `title` field under `books`, all fields have been returned, so this means fields filtering has applied on `CourseSerializer` but not on `BookSerializer` because we used `disable_dynamic_fields=True` on it. 689 |
690 | 691 | 692 | ## Query arguments 693 | 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 694 | 695 | ``` 696 | query = (age: 18){ 697 | name, 698 | age, 699 | location(country: Canada, city: Toronto){ 700 | country, 701 | city 702 | } 703 | } 704 | ``` 705 | Here we have three arguments, `age`, `country` and `city` and their corresponding values. 706 | 707 | 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. 708 | 709 | ``` 710 | query = (age: 18, join_date__lt: '2020-04-27T23:02:32Z'){ 711 | name, 712 | age, 713 | location(country: 'Canada', city: 'Toronto'){ 714 | country, 715 | city 716 | } 717 | } 718 | ``` 719 | 720 | 721 | ### Query arguments data types 722 | Django RESTQL supports five primitive data types for query arguments which are `String`, `Int`, `Float`, `Boolean`, and `null` 723 | 724 | The table below shows possible argument values and their corresponding python values 725 | 726 | | Argument Value | Python Value | 727 | | -------------- | ------------- | 728 | | String(e.g "Hi!" or 'Hi!') | Python String(e.g "Hi!" or 'Hi!') | 729 | | Int(e.g 25) | Python Int(e.g 25) | 730 | | Float(e.g 25.34) | Python Float(e.g 25.34) 731 | | true | True | 732 | | false | False | 733 | | null | None | 734 | 735 | Below is a query showing how these data types are used 736 | 737 | ``` 738 | query = (age__gt: 18, is_active: true, location__ne: null, height__gt: 5.4){ 739 | name, 740 | age, 741 | location(country: "Canada"){ 742 | country, 743 | city 744 | } 745 | } 746 | ``` 747 | 748 | 749 | ### Filtering & pagination with query arguments 750 | 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 751 | 752 | ```py 753 | # views.py 754 | 755 | from rest_framework import viewsets 756 | from django_restql.mixins import QueryArgumentsMixin 757 | 758 | class StudentViewSet(QueryArgumentsMixin, viewsets.ModelViewSet): 759 | serializer_class = StudentSerializer 760 | queryset = Student.objects.all() 761 | filter_fields = { 762 | 'name': ['exact'], 763 | 'age': ['exact'], 764 | 'location__country': ['exact'], 765 | 'location__city': ['exact'], 766 | } 767 | ``` 768 | 769 | 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 770 | 771 | ``` 772 | query = (age: 18){ 773 | name, 774 | age, 775 | location(country: Canada, city: Toronto){ 776 | country, 777 | city 778 | } 779 | } 780 | ``` 781 | 782 | Django RESTQL would generate three query parameters from this as shown below 783 | ```py 784 | query_params = {"age": 18, "location__country": "Canada", "location__city": "Toronto"} 785 | ``` 786 | These will be used by the filter backend you have set to do the actual filtering. 787 | 788 | 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 789 | 790 | ``` 791 | query = (limit: 20, offset: 50){ 792 | name, 793 | age, 794 | location{ 795 | country, 796 | city 797 | } 798 | } 799 | ``` 800 | 801 | Django RESTQL would generate two query parameters from this as shown below 802 | ```py 803 | query_params = {"limit": 20, "offset": 50} 804 | ``` 805 | These will be used by pagination class you have set to do the actual pagination. 806 | 807 | 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. 808 | 809 | 810 | ## Setting up eager loading 811 | 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. 812 | 813 | `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. 814 | 815 | ```py 816 | class CourseSerializer(DynamicFieldsMixin, serializers.ModelSerializer): 817 | books = BookSerializer(many=True, read_only=True) 818 | 819 | class Meta: 820 | model = Course 821 | fields = ['name', 'code', 'books'] 822 | 823 | class StudentSerializer(DynamicFieldsMixin, serializers.ModelSerializer): 824 | program = CourseSerializer(source="course", many=False, read_only=True) 825 | phone_numbers = PhoneSerializer(many=True, read_only=True) 826 | 827 | class Meta: 828 | model = Student 829 | fields = ['name', 'age', 'program', 'phone_numbers'] 830 | ``` 831 | 832 | 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. 833 | 834 | 835 | ### Syntax for prefetch_related and select_related 836 | The format of syntax for `select_related` and `prefetch_related` is as follows 837 | 838 | ```py 839 | select_related = {"serializer_field_name": ["field_to_select"]} 840 | prefetch_related = {"serializer_field_name": ["field_to_prefetch"]} 841 | ``` 842 | 843 | If you are selecting or prefetching one field per serializer field name you can use 844 | ```py 845 | select_related = {"serializer_field_name": "field_to_select"} 846 | prefetch_related = {"serializer_field_name": "field_to_prefetch"} 847 | ``` 848 | 849 | **Syntax Interpretation** 850 | 851 | * `serializer_field_name` stands for the name of the field to prefetch or select(as named on a serializer). 852 | * `fields_to_select` stands for argument(s) to pass when calling `select_related` method. 853 | * `fields_to_prefetch` stands for arguments(s) to pass when calling `prefetch_related` method. This can be a string or `Prefetch` object. 854 | * If you want to select or prefetch nested field use dot(.) to separate parent and child fields on `serializer_field_name` eg `parent.child`. 855 | 856 | 857 | ### Example of EagerLoadingMixin usage 858 | 859 | ```py 860 | from rest_framework import viewsets 861 | from django_restql.mixins import EagerLoadingMixin 862 | from myapp.serializers import StudentSerializer 863 | from myapp.models import Student 864 | 865 | class StudentViewSet(EagerLoadingMixin, viewsets.ModelViewSet): 866 | serializer_class = StudentSerializer 867 | queryset = Student.objects.all() 868 | 869 | # The Interpretation of this is 870 | # Select `course` only if program field is included in a query 871 | select_related = { 872 | "program": "course" 873 | } 874 | 875 | # The Interpretation of this is 876 | # Prefetch `course__books` only if program or program.books 877 | # fields are included in a query 878 | prefetch_related = { 879 | "program.books": "course__books" 880 | } 881 | ``` 882 | 883 |

Example Queries

884 | 885 | - `{name}`:    Neither `select_related` or `prefetch_related` will be run since neither field is present on the serializer for this query. 886 | 887 | - `{program}`:    Both `select_related` and `prefetch_related` will be run, since `program` is present in it's entirety (including the `books` field). 888 | 889 | - `{program{name}}`:    Only `select_related` will be run, since `books` are not present on the program fields. 890 | 891 | - `{program{books}}`:    Both will be run here as well, since this explicitly fetches books. 892 | 893 |

More example to get you comfortable with the syntax

894 | Assuming this is the structure of the model and corresponding field types 895 | 896 | ```py 897 | user = { 898 | username, # string 899 | birthdate, # string 900 | location { # foreign key related field 901 | country, # string 902 | city # string 903 | }, 904 | contact { # foreign key related field 905 | email, # string 906 | phone { # foreign key related field 907 | number, # string 908 | type # string 909 | } 910 | } 911 | articles { # many related field 912 | title, # string 913 | body, # text 914 | reviews { # many related field 915 | comment, # string 916 | rating # number 917 | } 918 | } 919 | } 920 | ``` 921 | 922 | Here is how `select_related` and `prefetch_related` could be written for this model 923 | ```py 924 | select_related = { 925 | "location": "location", 926 | "contact": "contact", 927 | "contact.phone": "contact__phone" 928 | } 929 | 930 | prefetch_related = { 931 | "articles": Prefetch("articles", queryset=Article.objects.all()), 932 | "articles.reviews": "articles__reviews" 933 | } 934 | ``` 935 | 936 | ### Known Caveats 937 | 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. 938 | 939 | 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. -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Django RESTQL 2 | 3 | theme: 4 | name: "material" 5 | language: en 6 | feature: 7 | tabs: false 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 | - admonition 23 | - codehilite: 24 | guess_lang: false 25 | - toc: 26 | permalink: true 27 | 28 | nav: 29 | - Intro: index.md 30 | - Querying Data: querying_data.md 31 | - Mutating Data: mutating_data.md 32 | - Settings: settings.md 33 | - License: license.md -------------------------------------------------------------------------------- /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 -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yezyilomo/django-restql/d00548f36986f2d8af36917f715e015ec6858f57/tests/__init__.py -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /tests/testapp/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation 3 | from django.contrib.contenttypes.models import ContentType 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, 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() 57 | content_type = models.ForeignKey(ContentType, 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 | -------------------------------------------------------------------------------- /tests/testapp/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | from tests.testapp.models import ( 3 | Genre, 4 | Book, 5 | Instructor, 6 | Course, 7 | Student, 8 | Phone, 9 | Attachment, 10 | Post, 11 | ) 12 | 13 | from django_restql.fields import NestedField, DynamicSerializerMethodField 14 | from django_restql.mixins import DynamicFieldsMixin 15 | from django_restql.serializers import NestedModelSerializer 16 | 17 | 18 | ######## Serializers for Data Querying And Mutations Testing ########## 19 | class GenreSerializer(DynamicFieldsMixin, serializers.ModelSerializer): 20 | class Meta: 21 | model = Genre 22 | fields = ["title", "description"] 23 | 24 | 25 | class InstructorSerializer(DynamicFieldsMixin, serializers.ModelSerializer): 26 | class Meta: 27 | model = Instructor 28 | fields = ["name"] 29 | 30 | 31 | class BookSerializer(DynamicFieldsMixin, serializers.ModelSerializer): 32 | class Meta: 33 | model = Book 34 | fields = ["title", "author"] 35 | 36 | 37 | class PhoneSerializer(DynamicFieldsMixin, serializers.ModelSerializer): 38 | class Meta: 39 | model = Phone 40 | fields = ["number", "type", "student"] 41 | 42 | 43 | ################# Serializers for Data Querying Testing ################ 44 | class CourseSerializer(DynamicFieldsMixin, serializers.ModelSerializer): 45 | books = BookSerializer(many=True, read_only=True) 46 | 47 | class Meta: 48 | model = Course 49 | fields = ["name", "code", "books"] 50 | 51 | 52 | class CourseWithDisableDynamicFieldsKwargSerializer( 53 | DynamicFieldsMixin, serializers.ModelSerializer 54 | ): 55 | books = BookSerializer(many=True, read_only=True, disable_dynamic_fields=True) 56 | 57 | class Meta: 58 | model = Course 59 | fields = ["name", "code", "books"] 60 | 61 | 62 | class CourseWithReturnPkkwargSerializer(CourseSerializer): 63 | books = BookSerializer(many=True, read_only=True, return_pk=True) 64 | 65 | class Meta: 66 | model = Course 67 | fields = ["name", "code", "books"] 68 | 69 | 70 | class CourseWithFieldsKwargSerializer(CourseSerializer): 71 | books = BookSerializer(many=True, read_only=True, fields=["title"]) 72 | 73 | class Meta(CourseSerializer.Meta): 74 | pass 75 | 76 | 77 | class CourseWithExcludeKwargSerializer(CourseSerializer): 78 | books = BookSerializer(many=True, read_only=True, exclude=["author"]) 79 | 80 | class Meta(CourseSerializer.Meta): 81 | pass 82 | 83 | 84 | class CourseWithAliasedBooksSerializer(CourseSerializer): 85 | tomes = BookSerializer(source="books", many=True, read_only=True) 86 | 87 | class Meta: 88 | model = Course 89 | fields = ["name", "code", "tomes"] 90 | 91 | 92 | class CourseWithDynamicSerializerMethodField(CourseSerializer): 93 | tomes = DynamicSerializerMethodField() 94 | related_books = DynamicSerializerMethodField() 95 | 96 | class Meta: 97 | model = Course 98 | fields = ["name", "code", "tomes", "related_books"] 99 | 100 | def get_tomes(self, obj, parsed_query): 101 | books = obj.books.all() 102 | serializer = BookSerializer( 103 | books, parsed_query=parsed_query, many=True, read_only=True 104 | ) 105 | return serializer.data 106 | 107 | def get_related_books(self, obj, parsed_query): 108 | books = obj.books.all() 109 | query = "{title}" 110 | serializer = BookSerializer(books, query=query, many=True, read_only=True) 111 | return serializer.data 112 | 113 | 114 | class StudentSerializer(DynamicFieldsMixin, serializers.ModelSerializer): 115 | course = CourseSerializer(many=False, read_only=True) 116 | phone_numbers = PhoneSerializer(many=True, read_only=True) 117 | 118 | class Meta: 119 | model = Student 120 | fields = ["name", "age", "course", "phone_numbers"] 121 | 122 | 123 | class StudentWithAliasSerializer(DynamicFieldsMixin, serializers.ModelSerializer): 124 | program = CourseSerializer(source="course", many=False, read_only=True) 125 | phone_numbers = PhoneSerializer(many=True, read_only=True) 126 | 127 | class Meta: 128 | model = Student 129 | fields = ["name", "age", "program", "phone_numbers"] 130 | 131 | 132 | ############### Serializers for Nested Data Mutation Testing ############## 133 | class WritableBookSerializer(DynamicFieldsMixin, NestedModelSerializer): 134 | genres = NestedField(GenreSerializer, many=True, required=False, partial=False) 135 | 136 | class Meta: 137 | model = Book 138 | fields = ["title", "author", "genres"] 139 | 140 | 141 | class WritableCourseSerializer(DynamicFieldsMixin, NestedModelSerializer): 142 | books = NestedField( 143 | WritableBookSerializer, many=True, required=False, allow_remove_all=True 144 | ) 145 | instructor = NestedField(InstructorSerializer, accept_pk=True, required=False) 146 | 147 | class Meta: 148 | model = Course 149 | fields = ["name", "code", "books", "instructor"] 150 | 151 | 152 | class ReplaceableStudentSerializer(DynamicFieldsMixin, NestedModelSerializer): 153 | course = NestedField( 154 | WritableCourseSerializer, accept_pk=True, allow_null=True, required=False 155 | ) 156 | phone_numbers = PhoneSerializer(many=True, read_only=True) 157 | 158 | class Meta: 159 | model = Student 160 | fields = ["name", "age", "course", "phone_numbers"] 161 | 162 | 163 | class ReplaceableStudentWithAliasSerializer(DynamicFieldsMixin, NestedModelSerializer): 164 | full_name = serializers.CharField(source="name") 165 | program = NestedField( 166 | WritableCourseSerializer, 167 | source="course", 168 | accept_pk_only=True, 169 | allow_null=True, 170 | required=False, 171 | ) 172 | contacts = NestedField( 173 | PhoneSerializer, source="phone_numbers", many=True, required=False 174 | ) 175 | 176 | class Meta: 177 | model = Student 178 | fields = ["full_name", "age", "program", "contacts"] 179 | 180 | 181 | class WritableStudentSerializer(DynamicFieldsMixin, NestedModelSerializer): 182 | course = NestedField(WritableCourseSerializer, allow_null=True, required=False) 183 | phone_numbers = NestedField( 184 | PhoneSerializer, many=True, required=False, allow_remove_all=True 185 | ) 186 | 187 | class Meta: 188 | model = Student 189 | fields = ["name", "age", "course", "phone_numbers"] 190 | 191 | 192 | class WritableStudentWithAliasSerializer(DynamicFieldsMixin, NestedModelSerializer): 193 | program = NestedField( 194 | WritableCourseSerializer, source="course", allow_null=True, required=False 195 | ) 196 | contacts = NestedField( 197 | PhoneSerializer, source="phone_numbers", many=True, required=False 198 | ) 199 | study_partner = NestedField( 200 | "self", 201 | required=False, 202 | allow_null=True, 203 | accept_pk=True, 204 | exclude=["study_partner"], 205 | ) 206 | sport_mates = NestedField( 207 | "self", 208 | required=False, 209 | many=True, 210 | source="sport_partners", 211 | exclude=["sport_mates"], 212 | ) 213 | 214 | class Meta: 215 | model = Student 216 | fields = ["name", "age", "program", "contacts", "study_partner", "sport_mates"] 217 | 218 | 219 | class AttachmentSerializer(serializers.ModelSerializer): 220 | class Meta: 221 | model = Attachment 222 | fields = ["content"] 223 | 224 | 225 | class PostSerializer(NestedModelSerializer): 226 | attachments = NestedField(AttachmentSerializer, many=True, required=False) 227 | 228 | class Meta: 229 | model = Post 230 | fields = ["title", "content", "attachments"] 231 | -------------------------------------------------------------------------------- /tests/testapp/views.py: -------------------------------------------------------------------------------- 1 | from django.db.models import Prefetch 2 | from rest_framework import viewsets 3 | 4 | from tests.testapp.models import Book, Course, Student, Phone, Post 5 | from tests.testapp.serializers import ( 6 | BookSerializer, 7 | CourseSerializer, 8 | StudentSerializer, 9 | CourseWithFieldsKwargSerializer, 10 | CourseWithExcludeKwargSerializer, 11 | CourseWithReturnPkkwargSerializer, 12 | ReplaceableStudentSerializer, 13 | WritableStudentSerializer, 14 | WritableCourseSerializer, 15 | CourseWithAliasedBooksSerializer, 16 | CourseWithDynamicSerializerMethodField, 17 | StudentWithAliasSerializer, 18 | WritableStudentWithAliasSerializer, 19 | ReplaceableStudentWithAliasSerializer, 20 | CourseWithDisableDynamicFieldsKwargSerializer, 21 | PostSerializer, 22 | ) 23 | 24 | from django_restql.mixins import EagerLoadingMixin, QueryArgumentsMixin 25 | 26 | 27 | #### ViewSets for Data Querying And Mutations Testing #### 28 | class BookViewSet(viewsets.ModelViewSet): 29 | serializer_class = BookSerializer 30 | queryset = Book.objects.all() 31 | 32 | 33 | class CourseViewSet(viewsets.ModelViewSet): 34 | serializer_class = CourseSerializer 35 | queryset = Course.objects.all() 36 | 37 | 38 | ############# ViewSets For Data Querying Testing ############# 39 | class CourseWithDisableDaynamicFieldsKwargViewSet(viewsets.ModelViewSet): 40 | serializer_class = CourseWithDisableDynamicFieldsKwargSerializer 41 | queryset = Course.objects.all() 42 | 43 | 44 | class CourseWithReturnPkkwargViewSet(viewsets.ModelViewSet): 45 | serializer_class = CourseWithReturnPkkwargSerializer 46 | queryset = Course.objects.all() 47 | 48 | 49 | class CourseWithFieldsKwargViewSet(viewsets.ModelViewSet): 50 | serializer_class = CourseWithFieldsKwargSerializer 51 | queryset = Course.objects.all() 52 | 53 | 54 | class CourseWithExcludeKwargViewSet(viewsets.ModelViewSet): 55 | serializer_class = CourseWithExcludeKwargSerializer 56 | queryset = Course.objects.all() 57 | 58 | 59 | class CourseWithAliasedBooksViewSet(viewsets.ModelViewSet): 60 | serializer_class = CourseWithAliasedBooksSerializer 61 | queryset = Course.objects.all() 62 | 63 | 64 | class CourseWithDynamicSerializerMethodFieldViewSet(viewsets.ModelViewSet): 65 | serializer_class = CourseWithDynamicSerializerMethodField 66 | queryset = Course.objects.all() 67 | 68 | 69 | class StudentViewSet(QueryArgumentsMixin, viewsets.ModelViewSet): 70 | serializer_class = StudentSerializer 71 | queryset = Student.objects.all() 72 | 73 | # For django-filter <=21.1 74 | filter_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 | # For django-filter > 21.1 84 | filterset_fields = { 85 | "name": ["exact"], 86 | "age": ["exact"], 87 | "course__name": ["exact"], 88 | "course__code": ["exact"], 89 | "course__books__title": ["exact"], 90 | "course__books__author": ["exact"], 91 | } 92 | 93 | 94 | class StudentEagerLoadingViewSet(EagerLoadingMixin, viewsets.ModelViewSet): 95 | serializer_class = StudentWithAliasSerializer 96 | queryset = Student.objects.all() 97 | select_related = {"program": "course"} 98 | prefetch_related = { 99 | "phone_numbers": "phone_numbers", 100 | "program.books": "course__books", 101 | } 102 | 103 | 104 | class StudentEagerLoadingPrefetchObjectViewSet( 105 | EagerLoadingMixin, viewsets.ModelViewSet 106 | ): 107 | serializer_class = StudentWithAliasSerializer 108 | queryset = Student.objects.all() 109 | select_related = {"program": "course"} 110 | prefetch_related = { 111 | "phone_numbers": [ 112 | Prefetch("phone_numbers", queryset=Phone.objects.all()), 113 | ], 114 | "program.books": Prefetch("course__books", queryset=Book.objects.all()), 115 | } 116 | 117 | 118 | class StudentAutoApplyEagerLoadingViewSet(EagerLoadingMixin, viewsets.ModelViewSet): 119 | serializer_class = StudentWithAliasSerializer 120 | queryset = Student.objects.all() 121 | auto_apply_eager_loading = False 122 | select_related = {"program": "course"} 123 | prefetch_related = { 124 | "phone_numbers": [ 125 | Prefetch("phone_numbers", queryset=Phone.objects.all()), 126 | ], 127 | "program.books": Prefetch("course__books", queryset=Book.objects.all()), 128 | } 129 | 130 | 131 | ######### ViewSets For Data Mutations Testing ########## 132 | class WritableCourseViewSet(viewsets.ModelViewSet): 133 | serializer_class = WritableCourseSerializer 134 | queryset = Course.objects.all() 135 | 136 | 137 | class ReplaceableStudentViewSet(viewsets.ModelViewSet): 138 | serializer_class = ReplaceableStudentSerializer 139 | queryset = Student.objects.all() 140 | 141 | 142 | class ReplaceableStudentWithAliasViewSet(viewsets.ModelViewSet): 143 | serializer_class = ReplaceableStudentWithAliasSerializer 144 | queryset = Student.objects.all() 145 | 146 | 147 | class WritableStudentViewSet(viewsets.ModelViewSet): 148 | serializer_class = WritableStudentSerializer 149 | queryset = Student.objects.all() 150 | 151 | 152 | class WritableStudentWithAliasViewSet(viewsets.ModelViewSet): 153 | serializer_class = WritableStudentWithAliasSerializer 154 | queryset = Student.objects.all() 155 | 156 | 157 | class PostViewSet(viewsets.ModelViewSet): 158 | serializer_class = PostSerializer 159 | queryset = Post.objects.all() 160 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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) --------------------------------------------------------------------------------