├── zen_queries ├── tests │ ├── __init__.py │ ├── templates │ │ ├── template.html │ │ ├── queries_disabled_tag.html │ │ └── queries_dangerously_enabled_tag.html │ ├── models.py │ ├── settings.py │ └── tests.py ├── templatetags │ ├── __init__.py │ └── zen_queries.py ├── utils.py ├── render.py ├── __init__.py ├── template_response.py ├── rest_framework.py └── decorators.py ├── setup.cfg ├── format ├── dev-requirements.txt ├── .gitignore ├── runtests ├── .isort.cfg ├── manage.py ├── .github └── workflows │ ├── pypi.yml │ └── ci.yml ├── LICENSE ├── setup.py └── README.md /zen_queries/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /zen_queries/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | extend_ignore=E128,E501,W503 3 | -------------------------------------------------------------------------------- /zen_queries/tests/templates/template.html: -------------------------------------------------------------------------------- 1 | {{ widgets.count }} 2 | -------------------------------------------------------------------------------- /format: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | black zen_queries 6 | isort zen_queries 7 | -------------------------------------------------------------------------------- /zen_queries/utils.py: -------------------------------------------------------------------------------- 1 | def fetch(queryset): 2 | queryset._fetch_all() 3 | return queryset 4 | -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | black==22.6.0 2 | djangorestframework==3.13.1 3 | flake8==4.0.1 4 | isort==5.10.1 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.db 3 | .coverage 4 | MANIFEST 5 | dist/ 6 | build/ 7 | env/ 8 | html/ 9 | htmlcov/ 10 | *.egg-info/ 11 | .tox/ 12 | -------------------------------------------------------------------------------- /zen_queries/tests/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class Widget(models.Model): 5 | name = models.CharField(max_length=100) 6 | -------------------------------------------------------------------------------- /zen_queries/tests/templates/queries_disabled_tag.html: -------------------------------------------------------------------------------- 1 | {% load zen_queries %} 2 | 3 | {% queries_disabled %} 4 | {{ widgets.count }} 5 | {% end_queries_disabled %} 6 | -------------------------------------------------------------------------------- /runtests: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | black --check zen_queries 6 | flake8 zen_queries 7 | isort --check --diff zen_queries 8 | python manage.py test --noinput $@ 9 | -------------------------------------------------------------------------------- /zen_queries/tests/templates/queries_dangerously_enabled_tag.html: -------------------------------------------------------------------------------- 1 | {% load zen_queries %} 2 | 3 | {% queries_dangerously_enabled %} 4 | {{ widgets.count }} 5 | {% end_queries_dangerously_enabled %} 6 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | multi_line_output=3 3 | include_trailing_comma=True 4 | force_grid_wrap=0 5 | use_parentheses=True 6 | line_length=88 7 | force_alphabetical_sort=True 8 | lines_between_types=0 9 | -------------------------------------------------------------------------------- /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", "zen_queries.tests.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /zen_queries/tests/settings.py: -------------------------------------------------------------------------------- 1 | DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}} 2 | 3 | INSTALLED_APPS = ["zen_queries", "zen_queries.tests"] 4 | 5 | TEMPLATES = [ 6 | {"BACKEND": "django.template.backends.django.DjangoTemplates", "APP_DIRS": True} 7 | ] 8 | 9 | SECRET_KEY = "abcde12345" 10 | 11 | DEFAULT_AUTO_FIELD = "django.db.models.AutoField" 12 | -------------------------------------------------------------------------------- /zen_queries/render.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render as django_render 2 | from zen_queries import queries_disabled 3 | 4 | 5 | def render(*args, **kwargs): 6 | """ 7 | Wrapper around Django's `render` shortcut that is 8 | not allowed to run database queries 9 | """ 10 | with queries_disabled(): 11 | response = django_render(*args, **kwargs) 12 | return response 13 | -------------------------------------------------------------------------------- /zen_queries/__init__.py: -------------------------------------------------------------------------------- 1 | from zen_queries.decorators import ( 2 | queries_dangerously_enabled, 3 | queries_disabled, 4 | QueriesDisabledError, 5 | ) 6 | from zen_queries.render import render 7 | from zen_queries.template_response import SimpleTemplateResponse, TemplateResponse 8 | from zen_queries.utils import fetch 9 | 10 | __version__ = "2.1.0" 11 | 12 | 13 | __all__ = [ 14 | "queries_disabled", 15 | "queries_dangerously_enabled", 16 | "QueriesDisabledError", 17 | "render", 18 | "TemplateResponse", 19 | "SimpleTemplateResponse", 20 | "fetch", 21 | ] 22 | -------------------------------------------------------------------------------- /zen_queries/template_response.py: -------------------------------------------------------------------------------- 1 | from django.template.response import ( 2 | SimpleTemplateResponse as DjangoSimpleTemplateResponse, 3 | ) 4 | from django.template.response import TemplateResponse as DjangoTemplateResponse 5 | from zen_queries import queries_disabled 6 | 7 | 8 | class RenderMixin(object): 9 | def render(self, *args, **kwargs): 10 | with queries_disabled(): 11 | return super(RenderMixin, self).render(*args, **kwargs) 12 | 13 | 14 | class TemplateResponse(RenderMixin, DjangoTemplateResponse): 15 | pass 16 | 17 | 18 | class SimpleTemplateResponse(RenderMixin, DjangoSimpleTemplateResponse): 19 | pass 20 | -------------------------------------------------------------------------------- /.github/workflows/pypi.yml: -------------------------------------------------------------------------------- 1 | name: Upload Python Package 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | - name: Set up Python 13 | uses: actions/setup-python@v2 14 | with: 15 | python-version: '3.8' 16 | - name: Install dependencies 17 | run: | 18 | python -m pip install --upgrade pip 19 | pip install setuptools wheel twine 20 | - name: Build and publish 21 | env: 22 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 23 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 24 | run: | 25 | python setup.py sdist bdist_wheel 26 | twine upload dist/* 27 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | strategy: 11 | matrix: 12 | python: ["3.8", "3.9", "3.10", "3.11"] 13 | django: ["3.2", "4.0", "4.1", "4.2"] 14 | exclude: 15 | - python: "3.11" 16 | django: "3.2" 17 | - python: "3.11" 18 | django: "4.0" 19 | 20 | steps: 21 | - uses: actions/checkout@v2 22 | - name: Set up Python ${{ matrix.python }} 23 | uses: actions/setup-python@v2 24 | with: 25 | python-version: ${{ matrix.python }} 26 | - name: Install package 27 | run: pip install -e . 28 | - name: Install dependencies 29 | run: pip install -r dev-requirements.txt 30 | - name: Install Django 31 | run: pip install -U django==${{ matrix.django }} 32 | - name: Run tests 33 | run: ./runtests 34 | -------------------------------------------------------------------------------- /zen_queries/templatetags/zen_queries.py: -------------------------------------------------------------------------------- 1 | from django.template import Library, Node 2 | 3 | import zen_queries 4 | 5 | register = Library() 6 | 7 | 8 | class QueriesDisabledNode(Node): 9 | def __init__(self, nodelist): 10 | self.nodelist = nodelist 11 | 12 | def render(self, context): 13 | with zen_queries.queries_disabled(): 14 | return self.nodelist.render(context) 15 | 16 | 17 | class QueriesDangerouslyEnabledNode(Node): 18 | def __init__(self, nodelist): 19 | self.nodelist = nodelist 20 | 21 | def render(self, context): 22 | with zen_queries.queries_dangerously_enabled(): 23 | return self.nodelist.render(context) 24 | 25 | 26 | @register.tag 27 | def queries_disabled(parser, token): 28 | nodelist = parser.parse(("end_queries_disabled",)) 29 | parser.delete_first_token() 30 | return QueriesDisabledNode(nodelist) 31 | 32 | 33 | @register.tag 34 | def queries_dangerously_enabled(parser, token): 35 | nodelist = parser.parse(("end_queries_dangerously_enabled",)) 36 | parser.delete_first_token() 37 | return QueriesDangerouslyEnabledNode(nodelist) 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020, DabApps 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 15 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 16 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 17 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 18 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 19 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 20 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 21 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 22 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 23 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | -------------------------------------------------------------------------------- /zen_queries/rest_framework.py: -------------------------------------------------------------------------------- 1 | from django.db.models import QuerySet 2 | from zen_queries import queries_disabled 3 | from zen_queries.utils import fetch 4 | 5 | 6 | class QueriesDisabledSerializerMixin: 7 | @classmethod 8 | def many_init(self, *args, **kwargs): 9 | """ 10 | Ensure queries are also disabled when `many=True`. 11 | """ 12 | return disable_serializer_queries(super().many_init(*args, **kwargs)) 13 | 14 | @property 15 | def data(self): 16 | with queries_disabled(): 17 | return super(QueriesDisabledSerializerMixin, self).data 18 | 19 | 20 | def disable_serializer_queries(serializer): 21 | serializer.__class__ = type( 22 | serializer.__class__.__name__, 23 | (QueriesDisabledSerializerMixin, serializer.__class__), 24 | {}, 25 | ) 26 | return serializer 27 | 28 | 29 | class QueriesDisabledViewMixin(object): 30 | def get_serializer(self, *args, **kwargs): 31 | serializer = super(QueriesDisabledViewMixin, self).get_serializer( 32 | *args, **kwargs 33 | ) 34 | if self.request.method == "GET": 35 | serializer = disable_serializer_queries(serializer) 36 | if isinstance(serializer.instance, QuerySet): 37 | # Serializer data must be fully evaluated prior to serialization. See #18. 38 | fetch(serializer.instance) 39 | return serializer 40 | -------------------------------------------------------------------------------- /zen_queries/decorators.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | from django.db import connections 3 | 4 | 5 | class QueriesDisabledError(Exception): 6 | pass 7 | 8 | 9 | def _raise_exception(execute, sql, params, many, context): 10 | raise QueriesDisabledError(sql) 11 | 12 | 13 | def _disable_queries(): 14 | for connection in connections.all(): 15 | connection.execute_wrappers.append(_raise_exception) 16 | 17 | 18 | def _enable_queries(): 19 | for connection in connections.all(): 20 | connection.execute_wrappers.pop() 21 | 22 | 23 | def _are_queries_disabled(): 24 | for connection in connections.all(): 25 | return _raise_exception in connection.execute_wrappers 26 | 27 | 28 | def _are_queries_dangerously_enabled(): 29 | for connection in connections.all(): 30 | return hasattr(connection, "_queries_dangerously_enabled") 31 | 32 | 33 | def _mark_as_dangerously_enabled(): 34 | for connection in connections.all(): 35 | connection._queries_dangerously_enabled = True 36 | 37 | 38 | def _mark_as_not_dangerously_enabled(): 39 | for connection in connections.all(): 40 | del connection._queries_dangerously_enabled 41 | 42 | 43 | @contextmanager 44 | def queries_disabled(): 45 | queries_already_disabled = _are_queries_disabled() 46 | if not queries_already_disabled and not _are_queries_dangerously_enabled(): 47 | _disable_queries() 48 | try: 49 | yield 50 | finally: 51 | if not queries_already_disabled and not _are_queries_dangerously_enabled(): 52 | _enable_queries() 53 | 54 | 55 | @contextmanager 56 | def queries_dangerously_enabled(): 57 | queries_dangerously_enabled_before = _are_queries_dangerously_enabled() 58 | if not queries_dangerously_enabled_before: 59 | _mark_as_dangerously_enabled() 60 | queries_disabled_before = _are_queries_disabled() 61 | if queries_disabled_before: 62 | _enable_queries() 63 | try: 64 | yield 65 | finally: 66 | if queries_disabled_before: 67 | _disable_queries() 68 | if not queries_dangerously_enabled_before: 69 | _mark_as_not_dangerously_enabled() 70 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from __future__ import print_function 5 | from setuptools import setup 6 | import re 7 | import os 8 | import sys 9 | 10 | 11 | name = "django-zen-queries" 12 | package = "zen_queries" 13 | description = "Explicit control over query execution in Django applications." 14 | url = "https://github.com/dabapps/django-zen-queries" 15 | author = "DabApps" 16 | author_email = "hello@dabapps.com" 17 | license = "BSD" 18 | 19 | with open("README.md") as f: 20 | readme = f.read() 21 | 22 | 23 | def get_version(package): 24 | """ 25 | Return package version as listed in `__version__` in `init.py`. 26 | """ 27 | init_py = open(os.path.join(package, "__init__.py")).read() 28 | return re.search("^__version__ = ['\"]([^'\"]+)['\"]", init_py, re.MULTILINE).group( 29 | 1 30 | ) 31 | 32 | 33 | def get_packages(package): 34 | """ 35 | Return root package and all sub-packages. 36 | """ 37 | return [ 38 | dirpath 39 | for dirpath, dirnames, filenames in os.walk(package) 40 | if os.path.exists(os.path.join(dirpath, "__init__.py")) 41 | ] 42 | 43 | 44 | def get_package_data(package): 45 | """ 46 | Return all files under the root package, that are not in a 47 | package themselves. 48 | """ 49 | walk = [ 50 | (dirpath.replace(package + os.sep, "", 1), filenames) 51 | for dirpath, dirnames, filenames in os.walk(package) 52 | if not os.path.exists(os.path.join(dirpath, "__init__.py")) 53 | ] 54 | 55 | filepaths = [] 56 | for base, filenames in walk: 57 | filepaths.extend([os.path.join(base, filename) for filename in filenames]) 58 | return {package: filepaths} 59 | 60 | 61 | if sys.argv[-1] == "publish": 62 | os.system("python setup.py sdist upload") 63 | args = {"version": get_version(package)} 64 | print("You probably want to also tag the version now:") 65 | print(" git tag -a %(version)s -m 'version %(version)s'" % args) 66 | print(" git push --tags") 67 | sys.exit() 68 | 69 | 70 | setup( 71 | name=name, 72 | version=get_version(package), 73 | url=url, 74 | license=license, 75 | description=description, 76 | long_description=readme, 77 | long_description_content_type="text/markdown", 78 | author=author, 79 | author_email=author_email, 80 | packages=get_packages(package), 81 | package_data=get_package_data(package), 82 | python_requires=">=3.6", 83 | install_requires=[ 84 | "Django>=3.2", 85 | ], 86 | project_urls={ 87 | "Changelog": "https://github.com/dabapps/django-zen-queries/releases", 88 | "Issues": "https://github.com/dabapps/django-zen-queries/issues", 89 | } 90 | ) 91 | -------------------------------------------------------------------------------- /zen_queries/tests/tests.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render as django_render 2 | from django.test import TestCase 3 | from rest_framework import serializers 4 | from zen_queries import ( 5 | fetch, 6 | queries_dangerously_enabled, 7 | queries_disabled, 8 | QueriesDisabledError, 9 | render, 10 | SimpleTemplateResponse, 11 | TemplateResponse, 12 | ) 13 | from zen_queries.rest_framework import ( 14 | disable_serializer_queries, 15 | QueriesDisabledSerializerMixin, 16 | QueriesDisabledViewMixin, 17 | ) 18 | from zen_queries.tests.models import Widget 19 | 20 | 21 | class ContextManagerTestCase(TestCase): 22 | def test_queries_disabled(self): 23 | with queries_disabled(): 24 | with self.assertRaises(QueriesDisabledError): 25 | Widget.objects.count() 26 | 27 | def test_nested_queries_disabled(self): 28 | with queries_disabled(): 29 | with self.assertRaises(QueriesDisabledError): 30 | Widget.objects.count() 31 | with queries_disabled(): 32 | with self.assertRaises(QueriesDisabledError): 33 | Widget.objects.count() 34 | with queries_disabled(): 35 | with self.assertRaises(QueriesDisabledError): 36 | Widget.objects.count() 37 | Widget.objects.count() 38 | 39 | def test_queries_enabled(self): 40 | with queries_disabled(): 41 | with queries_dangerously_enabled(): 42 | Widget.objects.count() 43 | 44 | def test_outer_queries_enabled(self): 45 | # enabling queries should always enable them, and subsequent 46 | # calls to disable should do nothing 47 | with queries_dangerously_enabled(): 48 | with queries_disabled(): 49 | Widget.objects.count() 50 | 51 | def test_nested_queries_enabled(self): 52 | with queries_disabled(): 53 | with queries_disabled(): 54 | with queries_dangerously_enabled(): 55 | with queries_disabled(): 56 | with queries_dangerously_enabled(): 57 | with queries_disabled(): 58 | Widget.objects.count() 59 | with self.assertRaises(QueriesDisabledError): 60 | Widget.objects.count() 61 | with self.assertRaises(QueriesDisabledError): 62 | Widget.objects.count() 63 | Widget.objects.count() 64 | 65 | def test_sql_in_exception(self): 66 | queryset = Widget.objects.all() 67 | with queries_disabled(): 68 | try: 69 | fetch(queryset) 70 | except QueriesDisabledError as e: 71 | self.assertEqual(str(e), str(queryset.query)) 72 | 73 | 74 | class FetchTestCase(TestCase): 75 | def test_fetch_all(self): 76 | with queries_disabled(): 77 | widgets = Widget.objects.all() 78 | with self.assertRaises(QueriesDisabledError): 79 | fetch(widgets) 80 | 81 | def test_returns_queryset(self): 82 | widgets = Widget.objects.all() 83 | fetched_widgets = fetch(widgets) 84 | self.assertIs(widgets, fetched_widgets) 85 | self.assertIsNotNone(widgets._result_cache) 86 | self.assertIsNotNone(fetched_widgets._result_cache) 87 | 88 | 89 | class RenderShortcutTestCase(TestCase): 90 | def test_render(self): 91 | widgets = Widget.objects.all() 92 | with self.assertRaises(QueriesDisabledError): 93 | render(None, "template.html", {"widgets": widgets}) 94 | 95 | 96 | class TemplateResponseTestCase(TestCase): 97 | def test_simple_template_response(self): 98 | widgets = Widget.objects.all() 99 | response = SimpleTemplateResponse("template.html", {"widgets": widgets}) 100 | with self.assertRaises(QueriesDisabledError): 101 | response.render() 102 | 103 | def test_template_response(self): 104 | widgets = Widget.objects.all() 105 | response = TemplateResponse(None, "template.html", {"widgets": widgets}) 106 | with self.assertRaises(QueriesDisabledError): 107 | response.render() 108 | 109 | 110 | class TemplateTagTestCase(TestCase): 111 | def test_queries_disabled_template_tag(self): 112 | widgets = Widget.objects.all() 113 | with self.assertRaises(QueriesDisabledError): 114 | django_render(None, "queries_disabled_tag.html", {"widgets": widgets}) 115 | 116 | def test_queries_dangerously_enabled_template_tag(self): 117 | widgets = Widget.objects.all() 118 | render(None, "queries_dangerously_enabled_tag.html", {"widgets": widgets}) 119 | 120 | 121 | class WidgetSerializer(serializers.ModelSerializer): 122 | class Meta: 123 | model = Widget 124 | fields = ["name"] 125 | 126 | 127 | class QueriesDisabledSerializer(QueriesDisabledSerializerMixin, WidgetSerializer): 128 | pass 129 | 130 | 131 | class SerializerMixinTestCase(TestCase): 132 | def test_serializer_mixin(self): 133 | serializer = QueriesDisabledSerializer(Widget.objects.all(), many=True) 134 | with self.assertRaises(QueriesDisabledError): 135 | serializer.data 136 | 137 | def test_add_mixin_to_instance(self): 138 | widgets = Widget.objects.all() 139 | serializer = WidgetSerializer(widgets, many=True) 140 | serializer = disable_serializer_queries(serializer) 141 | with self.assertRaises(QueriesDisabledError): 142 | serializer.data 143 | 144 | 145 | class FakeRequest(object): 146 | def __init__(self, method): 147 | self.method = method 148 | 149 | 150 | class FakeView(object): 151 | def get_serializer(self, *args, **kwargs): 152 | return WidgetSerializer(Widget.objects.all(), many=True) 153 | 154 | def handle_request(self, method): 155 | self.request = FakeRequest(method) 156 | return self.get_serializer().data 157 | 158 | 159 | class QueriesDisabledView(QueriesDisabledViewMixin, FakeView): 160 | pass 161 | 162 | 163 | class FakeListView(FakeView): 164 | def get_serializer(self, *args, **kwargs): 165 | return WidgetSerializer(list(Widget.objects.all()), many=True) 166 | 167 | 168 | class QueriesDisabledListView(QueriesDisabledViewMixin, FakeListView): 169 | pass 170 | 171 | 172 | class RESTFrameworkViewMixinTestCase(TestCase): 173 | def test_view_mixin(self): 174 | view = QueriesDisabledView() 175 | view.handle_request(method="GET") 176 | self.assertTrue( 177 | isinstance(view.get_serializer(), QueriesDisabledSerializerMixin) 178 | ) 179 | 180 | def test_post_ignored(self): 181 | view = QueriesDisabledView() 182 | view.handle_request(method="POST") 183 | self.assertFalse( 184 | isinstance(view.get_serializer(), QueriesDisabledSerializerMixin) 185 | ) 186 | 187 | def test_serializer_with_list(self): 188 | view = QueriesDisabledListView() 189 | view.handle_request(method="GET") 190 | self.assertTrue( 191 | isinstance(view.get_serializer(), QueriesDisabledSerializerMixin) 192 | ) 193 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | django-zen-queries 2 | ==================== 3 | 4 |  5 | [](https://pypi.python.org/pypi/django-zen-queries) 6 | 7 | 8 | Gives you control over which parts of your code are allowed to run queries, and which aren't. 9 | 10 | Tested against Django 3.2, 4.0, 4.1 and 4.2 on Python 3.8, 3.9, 3.10 and 3.11. 11 | 12 | #### Testimonial 13 | 14 | > Using `zen-queries` it became clear very quickly that I could not place any of my business logic in the template if I wanted to eradicate my pesky n+1 bug. `zen-queries` just would not let it happen. 15 | > 16 | > So I rethought the view and as recommended, judicious use of `select_related` and `prefetch_related` from the view level took me from over 4k DB queries to just 12 17 | > 18 | > [@ry_austin](https://twitter.com/ry_austin) 19 | 20 | ### Motivation 21 | 22 | > Explicit is better than implicit 23 | 24 | (The [Zen Of Python](https://www.python.org/dev/peps/pep-0020/)) 25 | 26 | The greatest strength of Django's ORM is also its greatest weakness. By freeing developers from having to think about when database queries are run, the ORM encourages developers to _not think about when database queries are run!_ This often has great benefits for quick development turnaround, but can have major performance implications in anything other than trivially simple systems. 27 | 28 | Django's ORM makes queries _implicit_. The Zen of Python tells us that **explicit is better than implicit**, so let's be explicit about which parts of our code are allowed to run queries, and which aren't. 29 | 30 | Check out [this blog post](https://www.dabapps.com/blog/performance-issues-caused-by-django-implicit-database-queries/) for more background. 31 | 32 | ### Example 33 | 34 | Imagine a pizza restaurant website with the following models: 35 | 36 | ```python 37 | class Topping(models.Model): 38 | name = models.CharField(max_length=100) 39 | 40 | 41 | class Pizza(models.Model): 42 | name = models.CharField(max_length=100) 43 | toppings = models.ManyToManyField(Topping) 44 | ``` 45 | 46 | And here's the menu view: 47 | 48 | ```python 49 | def menu(request): 50 | pizzas = Pizza.objects.all() 51 | context = {'pizzas': pizzas} 52 | return render(request, 'pizzas/menu.html', context) 53 | ``` 54 | 55 | Finally, the template: 56 | 57 | ```jinja2 58 |