pax_global_header00006660000000000000000000000064146504771410014523gustar00rootroot0000000000000052 comment=b24f2e548e2da98ab5bcf258169be6d1b8105b2a django-zeal-1.3.0/000077500000000000000000000000001465047714100137175ustar00rootroot00000000000000django-zeal-1.3.0/.github/000077500000000000000000000000001465047714100152575ustar00rootroot00000000000000django-zeal-1.3.0/.github/workflows/000077500000000000000000000000001465047714100173145ustar00rootroot00000000000000django-zeal-1.3.0/.github/workflows/release-please.yaml000066400000000000000000000005071465047714100230710ustar00rootroot00000000000000on: push: branches: - main permissions: contents: write pull-requests: write name: release-please jobs: release-please: runs-on: ubuntu-latest steps: - uses: googleapis/release-please-action@v4 with: token: ${{ secrets.RELEASE_PLEASE_TOKEN }} release-type: python django-zeal-1.3.0/.github/workflows/release.yaml000066400000000000000000000017411465047714100216230ustar00rootroot00000000000000name: Publish to PyPI on: push: tags: - '*' jobs: build: name: Build distribution runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: 3.12 - name: Install pypa/build run: >- python3 -m pip install build --user - name: Build run: python3 -m build - name: Store the distribution packages uses: actions/upload-artifact@v3 with: name: python-package-distributions path: dist/ publish: needs: - build runs-on: ubuntu-latest environment: name: pypi url: https://pypi.org/p/zealot permissions: id-token: write steps: - uses: actions/download-artifact@v3 with: name: python-package-distributions path: dist/ - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 django-zeal-1.3.0/.github/workflows/test.yaml000066400000000000000000000030341465047714100211570ustar00rootroot00000000000000name: Test on: [push] jobs: test: runs-on: ubuntu-latest strategy: matrix: python-version: ["3.9", "3.10", "3.11", "3.12"] django-version: ["4.2", "5.0"] exclude: # django 5 requires python >=3.10 - python-version: 3.9 django-version: 5.0 - python-version: 3.9 django-version: 5.1 name: Test (Python ${{ matrix.python-version }}, Django ${{ matrix.django-version }}) steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} cache: 'pip' - run: make ci - run: pip install Django~=${{ matrix.django-version }} - run: make test test-django-prerelease: runs-on: ubuntu-latest strategy: matrix: python-version: ["3.10", "3.11", "3.12"] name: Test Django prerelease (Python ${{ matrix.python-version }}) steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} cache: 'pip' - run: make ci - run: pip install --pre django - run: make test typecheck: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: 3.12 cache: 'pip' - run: make ci - run: make typecheck django-zeal-1.3.0/.gitignore000066400000000000000000000061211465047714100157070ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ cover/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder .pybuilder/ target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: # .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # poetry # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. # This is especially recommended for binary packages to ensure reproducibility, and is more # commonly ignored for libraries. # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control #poetry.lock # pdm # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. #pdm.lock # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it # in version control. # https://pdm.fming.dev/latest/usage/project/#working-with-version-control .pdm.toml .pdm-python .pdm-build/ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # pytype static type analyzer .pytype/ # Cython debug symbols cython_debug/ # PyCharm # JetBrains specific template is maintained in a separate JetBrains.gitignore that can # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ .ruff_cache/ django-zeal-1.3.0/.tool-versions000066400000000000000000000000161465047714100165400ustar00rootroot00000000000000python 3.9.19 django-zeal-1.3.0/CHANGELOG.md000066400000000000000000000055131465047714100155340ustar00rootroot00000000000000# Changelog ## [1.3.0](https://github.com/taobojlen/django-zeal/compare/v1.2.0...v1.3.0) (2024-07-25) ### Features * add ZEAL_SHOW_ALL_CALLERS to aid in debugging ([#17](https://github.com/taobojlen/django-zeal/issues/17)) ([7fdaf36](https://github.com/taobojlen/django-zeal/commit/7fdaf36db50fed6dee0b0544205e71035c977541)) ## [1.2.0](https://github.com/taobojlen/django-zeal/compare/v1.1.0...v1.2.0) (2024-07-22) ### Features * use warnings instead of logging ([#15](https://github.com/taobojlen/django-zeal/issues/15)) ([df2c841](https://github.com/taobojlen/django-zeal/commit/df2c841b21fae664c14356d00a7a2f6ecbb7fd61)) ## [1.1.0](https://github.com/taobojlen/django-zeal/compare/v1.0.0...v1.1.0) (2024-07-20) ### Features * allow ignoring specific models/fields in zeal_ignore ([#13](https://github.com/taobojlen/django-zeal/issues/13)) ([e51413b](https://github.com/taobojlen/django-zeal/commit/e51413ba5fe4d9a3c34409863e9888d873ff84fa)) ## [1.0.0](https://github.com/taobojlen/zealot/compare/v0.2.3...v1.0.0) (2024-07-20) ### ⚠ BREAKING CHANGES This project has been renamed to `zeal`. To migrate, replace `zealot` with `zeal` in your project's requirements. In your Django settings, replace `ZEALOT_ALLOWLIST`, `ZEALOT_RAISE`, etc. with `ZEAL_ALLOWLIST`, `ZEAL_RAISE`, and so on. In your code, replace `from zealot import ...` with `from zeal import ...`. ### Miscellaneous Chores * rename to zeal ([cc429a2](https://github.com/taobojlen/zealot/commit/cc429a26bfede770db69429e8a11fc9e98fbb2a9)) ## [0.2.3](https://github.com/taobojlen/zeal/compare/v0.2.2...v0.2.3) (2024-07-18) ### Bug Fixes * ensure context is reset after leaving ([#8](https://github.com/taobojlen/zeal/issues/8)) ([f45cabb](https://github.com/taobojlen/zeal/commit/f45cabb2abcabce34cd5aed163f7f95c71256e2c)) ## [0.2.2](https://github.com/taobojlen/zeal/compare/v0.2.1...v0.2.2) (2024-07-15) ### Bug Fixes * don't alert from calls on different lines ([7f7bda7](https://github.com/taobojlen/zeal/commit/7f7bda709e5fff2e953ddac0277d684255732e7c)) ## [0.2.1](https://github.com/taobojlen/zeal/compare/v0.2.0...v0.2.1) (2024-07-08) ### Bug Fixes * zeal_ignore always takes precedence ([e61d060](https://github.com/taobojlen/zeal/commit/e61d060c74ed32193c2c86f1b7f20929a37402a1)) ## [0.2.0](https://github.com/taobojlen/zeal/compare/v0.1.2...v0.2.0) (2024-07-06) ### Features * add support for python 3.9 ([#2](https://github.com/taobojlen/zeal/issues/2)) ([44e5f41](https://github.com/taobojlen/zeal/commit/44e5f41fc247e98683a1dd283ae70322a32445d6)) ## 0.1.2 - 2024-07-06 ### Fixed - Handle empty querysets - Handle incorrectly-used `.prefetch_related()` when `.select_related()` should have been used - Don't raise an exception when using `.values(...).get()` ## 0.1.1 - 2024-07-05 ### Fixed - Ignore N+1s from singly-loaded records ## 0.1.0 - 2024-05-03 Initial release. django-zeal-1.3.0/LICENSE000066400000000000000000000020661465047714100147300ustar00rootroot00000000000000Copyright 2016 Joshua Carp Copyright 2024 Tao Bojlén Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. django-zeal-1.3.0/Makefile000066400000000000000000000007131465047714100153600ustar00rootroot00000000000000install-hooks: git config core.hooksPath hooks/ install: $(MAKE) install-hooks uv pip compile requirements-dev.in -o requirements-dev.txt && uv pip sync requirements-dev.txt ci: pip install -r requirements-dev.txt test: pytest -s --tb=native --random-order $(ARGS) format-check: ruff format --check && ruff check format: ruff format && ruff check --fix typecheck: pyright . build: python -m build --installer uv publish: twine upload dist/* django-zeal-1.3.0/README.md000066400000000000000000000140361465047714100152020ustar00rootroot00000000000000# django-zeal Catch N+1 queries in your Django project. [![Static Badge](https://img.shields.io/badge/license-MIT-brightgreen)](https://github.com/taobojlen/django-zeal/blob/main/LICENSE) [![PyPI - Version](https://img.shields.io/pypi/v/django-zeal?color=lightgrey)](https://pypi.org/project/django-zeal/) 🔥 Battle-tested at [Cinder](https://www.cinder.co/) ## Features - Detects N+1s from missing prefetches and from use of `.defer()`/`.only()` - Friendly error messages like `N+1 detected on User.followers at myapp/views.py:25 in get_user` - Configurable thresholds - Allow-list - Well-tested - No dependencies ## Acknowledgements This library draws heavily from jmcarp's [nplusone](https://github.com/jmcarp/nplusone/). It's not a fork, but a lot of the central concepts and initial code came from nplusone. ## Installation First: ``` pip install django-zeal ``` Then, add zeal to your `INSTALLED_APPS` and `MIDDLEWARE`. You probably don't want to run it in production: I haven't profiled it but it will have a performance impact. ```python if DEBUG: INSTALLED_APPS.append("zeal") MIDDLEWARE.append("zeal.middleware.zeal_middleware") ``` This will detect N+1s that happen in web requests. To catch N+1s in more places, read on! ### Celery If you use Celery, you can configure this using [signals](https://docs.celeryq.dev/en/stable/userguide/signals.html): ```python from celery.signals import task_prerun, task_postrun from zeal import setup, teardown from django.conf import settings @task_prerun.connect() def setup_zeal(*args, **kwargs): setup() @task_postrun.connect() def teardown_zeal(*args, **kwargs): teardown() ``` ### Tests Django [runs tests with `DEBUG=False`](https://docs.djangoproject.com/en/5.0/topics/testing/overview/#other-test-conditions), so to run zeal in your tests, you'll first need to ensure it's added to your `INSTALLED_APPS` and `MIDDLEWARE`. You could do something like: ```python import sys TEST = "test" in sys.argv if DEBUG or TEST: INSTALLED_APPS.append("zeal") MIDDLEWARE.append("zeal.middleware.zeal_middleware") ``` This will enable zeal in any tests that go through your middleware. If you want to enable it in _all_ tests, you need to do a bit more work. If you use pytest, use a fixture in your `conftest.py`: ```python import pytest from zeal import zeal_context @pytest.fixture(scope="function", autouse=True) def use_zeal(): with zeal_context(): yield ``` If you use unittest, add custom test cases and inherit from these rather than directly from Django's test cases: ```python # In e.g. `myapp/testing/test_cases.py` from zeal import setup as zeal_setup, teardown as zeal_teardown import unittest from django.test import SimpleTestCase, TestCase, TransactionTestCase class ZealTestMixin(unittest.TestCase): def setUp(self, test): zeal_setup() super().setUp() def teardown(self) -> None: zeal_teardown() return super().teardown(test, err) class CustomSimpleTestCase(ZealTestMixin, SimpleTestCase): pass class CustomTestCase(ZealTestMixin, TestCase): pass class CustomTransactionTestCase(ZealTestMixin, TransactionTestCase): pass ``` ### Generic setup If you also want to detect N+1s in other places not covered here, you can use the `setup` and `teardown` functions, or the `zeal_context` context manager: ```python from zeal import setup, teardown, zeal_context def foo(): setup() try: # your code goes here finally: teardown() @zeal_context() def bar(): # your code goes here def baz(): with zeal_context(): # your code goes here ``` ## Configuration By default, any issues detected by zeal will raise a `ZealError`. If you'd rather log any detected N+1s as warnings, you can set: ```python ZEAL_RAISE = False ``` N+1s will be reported when the same query is executed twice. To configure this threshold, set the following in your Django settings. ```python ZEAL_NPLUSONE_THRESHOLD = 3 ``` To handle false positives, you can temporarily disable zeal in parts of your code using a context manager: ```python from zeal import zeal_ignore with zeal_ignore(): # code in this block will not log/raise zeal errors ``` If you only want to ignore a specific N+1, you can pass in a list of models/fields to ignore: ```python with zeal_ignore([{"model": "polls.Question", "field": "options"}]): # code in this block will ignore N+1s on Question.options ``` Finally, if you want to ignore N+1 alerts from a specific model/field globally, you can add it to your settings: ```python ZEAL_ALLOWLIST = [ {"model": "polls.Question", "field": "options"}, # you can use fnmatch syntax in the model/field, too {"model": "polls.*", "field": "options"}, # if you don't pass in a field, all N+1s arising from the model will be ignored {"model": "polls.Question"}, ] ``` ## Debugging N+1s By default, zeal's alerts will tell you the line of your code that executed the same query multiple times. If you'd like to see the full call stack from each time the query was executed, you can set: ```python ZEAL_SHOW_ALL_CALLERS = True ``` in your settings. This will give you the full call stack from each time the query was executed. ## Comparison to nplusone zeal borrows heavily from [nplusone](https://github.com/jmcarp/nplusone), but has some differences: - zeal also detects N+1 caused by using `.only()` and `.defer()` - it lets you configure your own threshold for what constitutes an N+1 - it has slightly more helpful error messages that tell you where the N+1 occurred - nplusone patches the Django ORM even in production when it's not enabled. zeal does not! - nplusone appears to be abandoned at this point. - however, zeal only works with Django, whereas nplusone can also be used with SQLAlchemy. - zeal does not (yet) detect unused prefetches, but nplusone does. ## Contributing 1. First, install [uv](https://github.com/astral-sh/uv). 2. Create a virtual env using `uv venv` and activate it with `source .venv/bin/activate`. 3. Run `make install` to install dev dependencies. 4. To run tests, run `make test`. django-zeal-1.3.0/hooks/000077500000000000000000000000001465047714100150425ustar00rootroot00000000000000django-zeal-1.3.0/hooks/pre-commit000077500000000000000000000001011465047714100170340ustar00rootroot00000000000000#!/bin/sh make format-check || exit 1 make typecheck || exit 1 django-zeal-1.3.0/hooks/pre-push000077500000000000000000000000371465047714100165330ustar00rootroot00000000000000#!/bin/sh make test || exit 1 django-zeal-1.3.0/pyproject.toml000066400000000000000000000013471465047714100166400ustar00rootroot00000000000000[project] name = "django-zeal" version = "1.3.0" description = "Detect N+1s in your Django app" readme = "README.md" license = { file = "LICENSE" } requires-python = ">=3.9" [tool.setuptools] package-dir = { "" = "src" } [tool.setuptools.packages.find] where = ["src"] [tool.ruff] line-length = 79 [tool.ruff.lint] extend-select = [ "I", # isort "N", # naming "B", # bugbear "FIX", # disallow FIXME/TODO comments "F", # pyflakes "T20", # flake8-print "ERA", # commented-out code "UP", # pyupgrade ] [tool.pyright] include = ["src", "tests"] [tool.pytest.ini_options] DJANGO_SETTINGS_MODULE = "djangoproject.settings" pythonpath = ["src", "tests"] testpaths = ["tests"] addopts = "--nomigrations" django-zeal-1.3.0/requirements-dev.in000066400000000000000000000002101465047714100175370ustar00rootroot00000000000000Django~=4.2 pytest~=8.2.2 pytest-django~=4.8.0 factory-boy~=3.3.0 ruff~=0.5.0 django-stubs~=5.0 pyright build twine pytest-random-order django-zeal-1.3.0/requirements-dev.txt000066400000000000000000000046671465047714100177740ustar00rootroot00000000000000# This file was autogenerated by uv via the following command: # uv pip compile requirements-dev.in -o requirements-dev.txt asgiref==3.8.1 # via # django # django-stubs backports-tarfile==1.2.0 # via jaraco-context build==1.2.1 # via -r requirements-dev.in certifi==2024.6.2 # via requests charset-normalizer==3.3.2 # via requests django==4.2.13 # via # -r requirements-dev.in # django-stubs # django-stubs-ext django-stubs==5.0.2 # via -r requirements-dev.in django-stubs-ext==5.0.2 # via django-stubs docutils==0.21.2 # via readme-renderer exceptiongroup==1.2.2 # via pytest factory-boy==3.3.0 # via -r requirements-dev.in faker==26.0.0 # via factory-boy idna==3.7 # via requests importlib-metadata==8.0.0 # via # build # keyring # twine iniconfig==2.0.0 # via pytest jaraco-classes==3.4.0 # via keyring jaraco-context==5.3.0 # via keyring jaraco-functools==4.0.1 # via keyring keyring==25.2.1 # via twine markdown-it-py==3.0.0 # via rich mdurl==0.1.2 # via markdown-it-py more-itertools==10.3.0 # via # jaraco-classes # jaraco-functools nh3==0.2.17 # via readme-renderer nodeenv==1.9.1 # via pyright packaging==24.1 # via # build # pytest pkginfo==1.10.0 # via twine pluggy==1.5.0 # via pytest pygments==2.18.0 # via # readme-renderer # rich pyproject-hooks==1.1.0 # via build pyright==1.1.369 # via -r requirements-dev.in pytest==8.2.2 # via # -r requirements-dev.in # pytest-django # pytest-random-order pytest-django==4.8.0 # via -r requirements-dev.in pytest-random-order==1.1.1 # via -r requirements-dev.in python-dateutil==2.9.0.post0 # via faker readme-renderer==43.0 # via twine requests==2.32.3 # via # requests-toolbelt # twine requests-toolbelt==1.0.0 # via twine rfc3986==2.0.0 # via twine rich==13.7.1 # via twine ruff==0.5.0 # via -r requirements-dev.in six==1.16.0 # via python-dateutil sqlparse==0.5.0 # via django tomli==2.0.1 # via # build # django-stubs # pytest twine==5.1.1 # via -r requirements-dev.in types-pyyaml==6.0.12.20240311 # via django-stubs typing-extensions==4.12.2 # via # asgiref # django-stubs # django-stubs-ext urllib3==2.2.2 # via # requests # twine zipp==3.19.2 # via importlib-metadata django-zeal-1.3.0/src/000077500000000000000000000000001465047714100145065ustar00rootroot00000000000000django-zeal-1.3.0/src/zeal/000077500000000000000000000000001465047714100154415ustar00rootroot00000000000000django-zeal-1.3.0/src/zeal/__init__.py000066400000000000000000000003501465047714100175500ustar00rootroot00000000000000from .errors import NPlusOneError, ZealError from .listeners import setup, teardown, zeal_context, zeal_ignore __all__ = [ "ZealError", "NPlusOneError", "setup", "teardown", "zeal_context", "zeal_ignore", ] django-zeal-1.3.0/src/zeal/apps.py000066400000000000000000000002231465047714100167530ustar00rootroot00000000000000from django.apps import AppConfig from .patch import patch class ZealConfig(AppConfig): name = "zeal" def ready(self): patch() django-zeal-1.3.0/src/zeal/errors.py000066400000000000000000000001201465047714100173200ustar00rootroot00000000000000class ZealError(Exception): pass class NPlusOneError(ZealError): pass django-zeal-1.3.0/src/zeal/listeners.py000066400000000000000000000130371465047714100200270ustar00rootroot00000000000000import inspect import logging import warnings from abc import ABC, abstractmethod from collections import defaultdict from contextlib import contextmanager from contextvars import ContextVar, Token from dataclasses import dataclass, field from fnmatch import fnmatch from typing import Optional, TypedDict from django.conf import settings from django.db import models from zeal.util import get_caller, get_stack from .errors import NPlusOneError, ZealError class QuerySource(TypedDict): model: type[models.Model] field: str instance_key: Optional[str] # e.g. `User:123` # tuple of (model, field, caller) CountsKey = tuple[type[models.Model], str, str] class AllowListEntry(TypedDict): model: str field: Optional[str] @dataclass class NPlusOneContext: calls: dict[CountsKey, list[list[inspect.FrameInfo]]] = field( default_factory=lambda: defaultdict(list) ) ignored: set[str] = field(default_factory=set) allowlist: list[AllowListEntry] = field(default_factory=list) _nplusone_context: ContextVar[NPlusOneContext] = ContextVar( "nplusone", default=NPlusOneContext(), ) logger = logging.getLogger("zeal") class Listener(ABC): @abstractmethod def notify(self, *args, **kwargs): ... @property @abstractmethod def error_class(self) -> type[ZealError]: ... @property def _allowlist(self) -> list[AllowListEntry]: if hasattr(settings, "ZEAL_ALLOWLIST"): settings_allowlist = settings.ZEAL_ALLOWLIST else: settings_allowlist = [] return [*settings_allowlist, *_nplusone_context.get().allowlist] def _alert( self, model: type[models.Model], field: str, message: str, calls: list[list[inspect.FrameInfo]], ): should_error = ( settings.ZEAL_RAISE if hasattr(settings, "ZEAL_RAISE") else True ) should_include_all_callers = ( settings.ZEAL_SHOW_ALL_CALLERS if hasattr(settings, "ZEAL_SHOW_ALL_CALLERS") else False ) is_allowlisted = False for entry in self._allowlist: model_match = fnmatch( f"{model._meta.app_label}.{model.__name__}", entry["model"] ) field_match = fnmatch(field, entry.get("field") or "*") if model_match and field_match: is_allowlisted = True break if is_allowlisted: return final_caller = get_caller() if should_include_all_callers: message = f"{message} with calls:\n" for i, caller in enumerate(calls): message += f"CALL {i+1}:\n" for frame in caller: message += f" {frame.filename}:{frame.lineno} in {frame.function}\n" else: message = f"{message} at {final_caller.filename}:{final_caller.lineno} in {final_caller.function}" if should_error: raise self.error_class(message) else: warnings.warn_explicit( message, UserWarning, filename=final_caller.filename, lineno=final_caller.lineno, ) class NPlusOneListener(Listener): @property def error_class(self): return NPlusOneError def notify( self, model: type[models.Model], field: str, instance_key: Optional[str], ): context = _nplusone_context.get() caller = get_caller() key = (model, field, f"{caller.filename}:{caller.lineno}") context.calls[key].append(get_stack()) count = len(context.calls[key]) if count >= self._threshold and instance_key not in context.ignored: message = f"N+1 detected on {model.__name__}.{field}" self._alert(model, field, message, context.calls[key]) _nplusone_context.set(context) def ignore(self, instance_key: Optional[str]): """ Tells the listener to ignore N+1s arising from this instance. This is used when the given instance is singly-loaded, e.g. via `.first()` or `.get()`. This is to prevent false positives. """ context = _nplusone_context.get() if not instance_key: return context.ignored.add(instance_key) _nplusone_context.set(context) @property def _threshold(self) -> int: if hasattr(settings, "ZEAL_NPLUSONE_THRESHOLD"): return settings.ZEAL_NPLUSONE_THRESHOLD else: return 2 n_plus_one_listener = NPlusOneListener() def setup() -> Optional[Token]: # if we're already in an ignore-context, we don't want to override # it. context = _nplusone_context.get() return _nplusone_context.set(NPlusOneContext(allowlist=context.allowlist)) def teardown(token: Optional[Token] = None): if token: _nplusone_context.reset(token) else: _nplusone_context.set(NPlusOneContext()) @contextmanager def zeal_context(): token = setup() try: yield finally: teardown(token) @contextmanager def zeal_ignore(allowlist: Optional[list[AllowListEntry]] = None): if allowlist is None: allowlist = [{"model": "*", "field": "*"}] old_context = _nplusone_context.get() new_context = NPlusOneContext( calls=old_context.calls.copy(), ignored=old_context.ignored.copy(), allowlist=[*old_context.allowlist, *allowlist], ) token = _nplusone_context.set(new_context) try: yield finally: _nplusone_context.reset(token) django-zeal-1.3.0/src/zeal/middleware.py000066400000000000000000000003371465047714100201330ustar00rootroot00000000000000from .listeners import zeal_context def zeal_middleware(get_response): def middleware(request): with zeal_context(): response = get_response(request) return response return middleware django-zeal-1.3.0/src/zeal/patch.py000066400000000000000000000244271465047714100171230ustar00rootroot00000000000000import functools import importlib import inspect from typing import Any, Callable, Optional, TypedDict, Union from django.db import models from django.db.models.fields.related_descriptors import ( ForwardManyToOneDescriptor, ReverseOneToOneDescriptor, create_forward_many_to_many_manager, create_reverse_many_to_one_manager, ) from django.db.models.query import QuerySet from django.db.models.query_utils import DeferredAttribute from zeal.util import is_single_query from .listeners import QuerySource, n_plus_one_listener class QuerysetContext(TypedDict): args: Optional[Any] kwargs: Optional[Any] # This is only used for many-to-many relations. It contains the call args # when `create_forward_many_to_many_manager` is called. manager_call_args: Optional[dict[str, Any]] # used by ReverseManyToOne. a django model instance. instance: Optional[models.Model] Parser = Callable[[QuerysetContext], QuerySource] def get_instance_key( instance: Union[models.Model, dict[str, Any]], ) -> Optional[str]: if isinstance(instance, models.Model): return f"{instance.__class__.__name__}:{instance.pk}" else: # when calling a queryset with `.values(...).get()`, the instance # we get here may be a dict. we don't handle that case formally, # so we return None to ignore that instance in our listeners. return None def patch_module_function(original, patched): module = importlib.import_module(original.__module__) setattr(module, original.__name__, patched) def patch_queryset_fetch_all( queryset: models.QuerySet, parser: Parser, context: QuerysetContext ): fetch_all = queryset._fetch_all @functools.wraps(fetch_all) def wrapper(*args, **kwargs): if queryset._result_cache is None: parsed = parser(context) n_plus_one_listener.notify( parsed["model"], parsed["field"], parsed["instance_key"], ) return fetch_all(*args, **kwargs) return wrapper def patch_queryset_function( queryset_func: Callable[..., models.QuerySet], parser: Parser, context: Optional[QuerysetContext] = None, ): if context is None: context = { "args": None, "kwargs": None, "manager_call_args": None, "instance": None, } @functools.wraps(queryset_func) def wrapper(*args, **kwargs): queryset = queryset_func(*args, **kwargs) # don't patch the same queryset more than once if ( hasattr(queryset, "__zeal_patched") and queryset.__zeal_patched # type: ignore ): return queryset if args and args != context.get("args"): context["args"] = args if kwargs and kwargs != context.get("kwargs"): context["kwargs"] = kwargs queryset._clone = patch_queryset_function( # type: ignore queryset._clone, # type: ignore parser, context=context, ) queryset._fetch_all = patch_queryset_fetch_all( queryset, parser, context ) queryset.__zeal_patched = True # type: ignore return queryset return wrapper def patch_forward_many_to_one_descriptor(): """ This also handles ForwardOneToOneDescriptor, which is a subclass of ForwardManyToOneDescriptor. """ def parser(context: QuerysetContext) -> QuerySource: assert "args" in context and context["args"] is not None descriptor = context["args"][0] if "kwargs" in context and context["kwargs"] is not None: instance = context["kwargs"]["instance"] instance_key = get_instance_key(instance) else: # `get_queryset` can in some cases be called without any # kwargs. In those cases, we ignore the instance. instance_key = None return { "model": descriptor.field.model, "field": descriptor.field.name, "instance_key": instance_key, } ForwardManyToOneDescriptor.get_queryset = patch_queryset_function( ForwardManyToOneDescriptor.get_queryset, parser=parser ) def parse_related_parts( model: type[models.Model], related_name: Optional[str], related_model: type[models.Model], ) -> tuple[type[models.Model], str]: field_name = related_name or f"{related_model._meta.model_name}_set" return (model, field_name) def patch_reverse_many_to_one_descriptor(): def parser(context: QuerysetContext) -> QuerySource: assert ( "manager_call_args" in context and context["manager_call_args"] is not None and "rel" in context["manager_call_args"] ) assert "instance" in context and context["instance"] is not None rel = context["manager_call_args"]["rel"] model, field = parse_related_parts( rel.model, rel.related_name, rel.related_model ) return { "model": model, "field": field, "instance_key": get_instance_key(context["instance"]), } def patched_create_reverse_many_to_one_manager(*args, **kwargs): manager_call_args = inspect.getcallargs( create_reverse_many_to_one_manager, *args, **kwargs ) manager = create_reverse_many_to_one_manager(*args, **kwargs) def patch_init_method(func): @functools.wraps(func) def wrapper(self, instance): self.get_queryset = patch_queryset_function( self.get_queryset, parser, context={ "args": None, "kwargs": None, "manager_call_args": manager_call_args, "instance": instance, }, ) return func(self, instance) return wrapper manager.__init__ = patch_init_method(manager.__init__) # type: ignore return manager patch_module_function( create_reverse_many_to_one_manager, patched_create_reverse_many_to_one_manager, ) def patch_reverse_one_to_one_descriptor(): def parser(context: QuerysetContext) -> QuerySource: assert "args" in context and context["args"] is not None descriptor = context["args"][0] field = descriptor.related.field if "kwargs" in context and context["kwargs"] is not None: instance = context["kwargs"]["instance"] instance_key = get_instance_key(instance) else: instance_key = None return { "model": field.related_model, "field": field.remote_field.name, "instance_key": instance_key, } ReverseOneToOneDescriptor.get_queryset = patch_queryset_function( ReverseOneToOneDescriptor.get_queryset, parser ) def patch_many_to_many_descriptor(): def parser(context: QuerysetContext) -> QuerySource: assert ( "manager_call_args" in context and context["manager_call_args"] is not None and "rel" in context["manager_call_args"] ) assert "args" in context and context["args"] is not None rel = context["manager_call_args"]["rel"] manager = context["args"][0] model = manager.instance.__class__ related_model = manager.target_field.related_model field_name = manager.prefetch_cache_name if rel.related_name else None model, field_name = parse_related_parts( model, field_name, related_model ) return { "model": model, "field": field_name, "instance_key": get_instance_key(manager.instance), } def patched_create_forward_many_to_many_manager(*args, **kwargs): manager_call_args = inspect.getcallargs( create_forward_many_to_many_manager, *args, **kwargs ) manager = create_forward_many_to_many_manager(*args, **kwargs) manager.get_queryset = patch_queryset_function( manager.get_queryset, parser, context={ "args": None, "kwargs": None, "manager_call_args": manager_call_args, "instance": None, }, ) return manager patch_module_function( create_forward_many_to_many_manager, patched_create_forward_many_to_many_manager, ) def patch_deferred_attribute(): def patched_check_parent_chain(func): @functools.wraps(func) def wrapper(self, instance, *args, **kwargs): result = func(self, instance, *args, **kwargs) if result is None: n_plus_one_listener.notify( instance.__class__, self.field.name, str(instance.pk) ) return result return wrapper DeferredAttribute._check_parent_chain = patched_check_parent_chain( # type: ignore DeferredAttribute._check_parent_chain # type: ignore ) def patch_global_queryset(): """ We patch `_fetch_all` and `.get()` on querysets to let us ignore singly-loaded instances. We don't want to trigger N+1 errors from such instances because of the high false positive rate. """ def patch_fetch_all(func): @functools.wraps(func) def wrapper(self, *args, **kwargs): should_ignore = ( is_single_query(self.query) and self._result_cache is None ) ret = func(self, *args, **kwargs) # call the original _fetch_all if should_ignore and len(self) > 0: n_plus_one_listener.ignore(get_instance_key(self[0])) return ret return wrapper QuerySet._fetch_all = patch_fetch_all(QuerySet._fetch_all) def patch_get(func): @functools.wraps(func) def wrapper(*args, **kwargs): ret = func(*args, **kwargs) n_plus_one_listener.ignore(get_instance_key(ret)) return ret return wrapper QuerySet.get = patch_get(QuerySet.get) def patch(): patch_forward_many_to_one_descriptor() patch_reverse_many_to_one_descriptor() patch_reverse_one_to_one_descriptor() patch_many_to_many_descriptor() patch_deferred_attribute() patch_global_queryset() django-zeal-1.3.0/src/zeal/util.py000066400000000000000000000014351465047714100167730ustar00rootroot00000000000000import inspect from django.db.models.sql import Query PATTERNS = [ "site-packages", "zeal/listeners.py", "zeal/patch.py", "zeal/util.py", ] def get_stack() -> list[inspect.FrameInfo]: """ Returns the current call stack, excluding any code in site-packages or zeal. """ return [ frame for frame in inspect.stack()[1:] if not any(pattern in frame.filename for pattern in PATTERNS) ] def get_caller() -> inspect.FrameInfo: """ Returns the filename and line number of the current caller, excluding any code in site-packages or zeal. """ return next(frame for frame in get_stack()) def is_single_query(query: Query): return ( query.high_mark is not None and query.high_mark - query.low_mark == 1 ) django-zeal-1.3.0/tests/000077500000000000000000000000001465047714100150615ustar00rootroot00000000000000django-zeal-1.3.0/tests/__init__.py000066400000000000000000000000011465047714100171610ustar00rootroot00000000000000 django-zeal-1.3.0/tests/conftest.py000066400000000000000000000002251465047714100172570ustar00rootroot00000000000000import pytest from zeal import zeal_context @pytest.fixture(scope="function", autouse=True) def use_zeal(): with zeal_context(): yield django-zeal-1.3.0/tests/djangoproject/000077500000000000000000000000001465047714100177125ustar00rootroot00000000000000django-zeal-1.3.0/tests/djangoproject/__init__.py000066400000000000000000000000001465047714100220110ustar00rootroot00000000000000django-zeal-1.3.0/tests/djangoproject/manage.py000077500000000000000000000012361465047714100215210ustar00rootroot00000000000000#!/usr/bin/env python """Django's command-line utility for administrative tasks.""" import os import sys def main(): """Run administrative tasks.""" os.environ.setdefault("DJANGO_SETTINGS_MODULE", "djangoproject.settings") try: from django.core.management import execute_from_command_line except ImportError as exc: raise ImportError( "Couldn't import Django. Are you sure it's installed and " "available on your PYTHONPATH environment variable? Did you " "forget to activate a virtual environment?" ) from exc execute_from_command_line(sys.argv) if __name__ == "__main__": main() django-zeal-1.3.0/tests/djangoproject/settings.py000066400000000000000000000010651465047714100221260ustar00rootroot00000000000000SECRET_KEY = 1 DEBUG = True USE_TZ = True TIME_ZONE = "UTC" INSTALLED_APPS = [ "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", "djangoproject.social", "zeal", ] MIDDLEWARE = ["zeal.middleware.zeal_middleware"] ROOT_URLCONF = "djangoproject.urls" DATABASES = { "default": { "ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:", } } DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" django-zeal-1.3.0/tests/djangoproject/social/000077500000000000000000000000001465047714100211645ustar00rootroot00000000000000django-zeal-1.3.0/tests/djangoproject/social/__init__.py000066400000000000000000000000001465047714100232630ustar00rootroot00000000000000django-zeal-1.3.0/tests/djangoproject/social/apps.py000066400000000000000000000002361465047714100225020ustar00rootroot00000000000000from django.apps import AppConfig class SocialConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "djangoproject.social" django-zeal-1.3.0/tests/djangoproject/social/models.py000066400000000000000000000016651465047714100230310ustar00rootroot00000000000000from django.db import models class User(models.Model): username = models.TextField() # user.followers and user.following are both ManyToManyDescriptor following = models.ManyToManyField("User", related_name="followers") # note that there's no related_name set here, because we want to # test that case too. blocked = models.ManyToManyField("user") followers: models.Manager["User"] user_set: models.Manager["User"] posts: models.Manager["Post"] profile: "Profile" class Profile(models.Model): # profile.user is ForwardOneToOne # user.profile is ReverseOneToOne user = models.OneToOneField(User, on_delete=models.CASCADE) display_name = models.TextField() class Post(models.Model): # post.author is ForwardManyToOne # user.posts is ReverseManyToOne author = models.ForeignKey( User, on_delete=models.CASCADE, related_name="posts" ) text = models.TextField() django-zeal-1.3.0/tests/djangoproject/social/views.py000066400000000000000000000012771465047714100227020ustar00rootroot00000000000000from django.http import HttpRequest, JsonResponse from .models import User def single_user_and_profile(request: HttpRequest, id: int): user = User.objects.get(id=id) return JsonResponse( data={ "username": user.username, "display_name": user.profile.display_name, } ) def all_users_and_profiles(request: HttpRequest): """ This view has an N+1. """ return JsonResponse( data={ "users": [ { "username": user.username, "display_name": user.profile.display_name, } for user in User.objects.all() ] } ) django-zeal-1.3.0/tests/djangoproject/urls.py000066400000000000000000000003341465047714100212510ustar00rootroot00000000000000from django.urls import path from .social.views import all_users_and_profiles, single_user_and_profile urlpatterns = [ path("users/", all_users_and_profiles), path("user//", single_user_and_profile), ] django-zeal-1.3.0/tests/factories.py000066400000000000000000000012761465047714100174200ustar00rootroot00000000000000from typing import Generic, TypeVar import factory from djangoproject.social.models import Post, Profile, User T = TypeVar("T") class BaseFactory(Generic[T], factory.django.DjangoModelFactory): @classmethod def create(cls, **kwargs) -> T: return super().create(**kwargs) class UserFactory(BaseFactory[User]): username = factory.Faker("user_name") class Meta: # type: ignore model = User class ProfileFactory(BaseFactory[Profile]): display_name = factory.Faker("name") class Meta: # type: ignore model = Profile class PostFactory(BaseFactory[Post]): text = factory.Faker("sentence") class Meta: # type: ignore model = Post django-zeal-1.3.0/tests/test_listeners.py000066400000000000000000000164351465047714100205130ustar00rootroot00000000000000import re import warnings import pytest from djangoproject.social.models import Post, User from zeal import NPlusOneError, zeal_context, zeal_ignore from zeal.listeners import _nplusone_context, n_plus_one_listener from .factories import PostFactory, UserFactory pytestmark = pytest.mark.django_db def test_can_log_errors(settings, caplog): settings.ZEAL_RAISE = False [user_1, user_2] = UserFactory.create_batch(2) PostFactory.create(author=user_1) PostFactory.create(author=user_2) with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") for user in User.objects.all(): _ = list(user.posts.all()) assert len(w) == 1 assert issubclass(w[0].category, UserWarning) assert re.search( r"N\+1 detected on User\.posts at .*\/test_listeners\.py:23 in test_can_log_errors", str(w[0].message), ) def test_can_log_all_traces(settings): settings.ZEAL_SHOW_ALL_CALLERS = True settings.ZEAL_RAISE = False [user_1, user_2] = UserFactory.create_batch(2) PostFactory.create(author=user_1) PostFactory.create(author=user_2) with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") for user in User.objects.all(): _ = list(user.posts.all()) assert len(w) == 1 assert issubclass(w[0].category, UserWarning) expected_lines = [ "N+1 detected on User.posts with calls:", "CALL 1:", "tests/test_listeners.py:41 in test_can_log_all_traces", "CALL 2:", "tests/test_listeners.py:41 in test_can_log_all_traces", ] for line in expected_lines: assert line in str(w[0].message) def test_errors_include_caller(): [user_1, user_2] = UserFactory.create_batch(2) PostFactory.create(author=user_1) PostFactory.create(author=user_2) with pytest.raises( NPlusOneError, match=r"N\+1 detected on User\.posts at .*\/test_listeners\.py:64 in test_errors_include_caller", ): for user in User.objects.all(): _ = list(user.posts.all()) def test_can_exclude_with_allowlist(settings): settings.ZEAL_ALLOWLIST = [{"model": "social.User", "field": "posts"}] [user_1, user_2] = UserFactory.create_batch(2) PostFactory.create(author=user_1) PostFactory.create(author=user_2) # this will not raise, allow-listed for user in User.objects.all(): _ = list(user.posts.all()) with pytest.raises(NPlusOneError): for post in Post.objects.all(): _ = post.author def test_can_use_fnmatch_pattern_in_allowlist_model(settings): settings.ZEAL_ALLOWLIST = [{"model": "social.U*"}] [user_1, user_2] = UserFactory.create_batch(2) PostFactory.create(author=user_1) PostFactory.create(author=user_2) # this will not raise, allow-listed for user in User.objects.all(): _ = list(user.posts.all()) with pytest.raises(NPlusOneError): for post in Post.objects.all(): _ = post.author def test_can_use_fnmatch_pattern_in_allowlist_field(settings): settings.ZEAL_ALLOWLIST = [{"model": "social.User", "field": "p*st*"}] [user_1, user_2] = UserFactory.create_batch(2) PostFactory.create(author=user_1) PostFactory.create(author=user_2) # this will not raise, allow-listed for user in User.objects.all(): _ = list(user.posts.all()) with pytest.raises(NPlusOneError): for post in Post.objects.all(): _ = post.author def test_ignore_context_takes_precedence(): """ If you're within a `zeal_ignore` context, then even if some later code adds a zeal context, then the ignore context should take precedence. """ with zeal_ignore(): with zeal_context(): [user_1, user_2] = UserFactory.create_batch(2) PostFactory.create(author=user_1) PostFactory.create(author=user_2) # this will not raise because we're in the zeal_ignore context for user in User.objects.all(): _ = list(user.posts.all()) def test_reverts_to_previous_state_when_leaving_zeal_ignore(): # we are currently in a zeal context initial_context = _nplusone_context.get() with zeal_ignore(): assert _nplusone_context.get().allowlist == [ {"model": "*", "field": "*"} ] assert _nplusone_context.get() == initial_context def test_resets_state_in_nested_context(): [user_1, user_2] = UserFactory.create_batch(2) PostFactory.create(author=user_1) PostFactory.create(author=user_2) # we're already in a zeal_context within each test, so let's set # some state. n_plus_one_listener.ignore("Test:1") n_plus_one_listener.notify(Post, "test_field", "Post:1") context = _nplusone_context.get() assert context.ignored == {"Test:1"} assert len(context.calls.values()) == 1 caller = list(context.calls.values())[0] with zeal_context(): # new context, fresh state context = _nplusone_context.get() assert context.ignored == set() assert list(context.calls.values()) == [] n_plus_one_listener.ignore("NestedTest:1") n_plus_one_listener.notify(Post, "nested_test_field", "Post:1") context = _nplusone_context.get() assert context.ignored == {"NestedTest:1"} assert len(list(context.calls.values())) == 1 # back outside the nested context, we're back to the old state context = _nplusone_context.get() assert context.ignored == {"Test:1"} assert list(context.calls.values()) == [caller] def test_can_ignore_specific_models(): [user_1, user_2] = UserFactory.create_batch(2) PostFactory.create(author=user_1) PostFactory.create(author=user_2) with zeal_ignore([{"model": "social.User", "field": "post*"}]): # this will not raise, allow-listed for user in User.objects.all(): _ = list(user.posts.all()) with pytest.raises(NPlusOneError): # this is *not* allowlisted for post in Post.objects.all(): _ = post.author # if we ignore another field, we still raise with zeal_ignore([{"model": "social.User", "field": "foobar"}]): with pytest.raises(NPlusOneError): for user in User.objects.all(): _ = list(user.posts.all()) # outside of the context, we're back to normal with pytest.raises(NPlusOneError): for user in User.objects.all(): _ = list(user.posts.all()) def test_context_specific_allowlist_merges(): [user_1, user_2] = UserFactory.create_batch(2) PostFactory.create(author=user_1) PostFactory.create(author=user_2) with zeal_ignore([{"model": "social.User", "field": "post*"}]): # this will not raise, allow-listed for user in User.objects.all(): _ = list(user.posts.all()) with pytest.raises(NPlusOneError): # this is *not* allowlisted for post in Post.objects.all(): _ = post.author with zeal_ignore([{"model": "social.Post", "field": "author"}]): for post in Post.objects.all(): _ = post.author # this is still allowlisted for user in User.objects.all(): _ = list(user.posts.all()) django-zeal-1.3.0/tests/test_nplusones.py000066400000000000000000000417341465047714100205310ustar00rootroot00000000000000import re import pytest from django.db import connection from django.test.utils import CaptureQueriesContext from djangoproject.social.models import Post, Profile, User from zeal import NPlusOneError, zeal_context from zeal.listeners import zeal_ignore from .factories import PostFactory, ProfileFactory, UserFactory pytestmark = pytest.mark.django_db def test_detects_nplusone_in_forward_many_to_one(): [user_1, user_2] = UserFactory.create_batch(2) PostFactory.create(author=user_1) PostFactory.create(author=user_2) with pytest.raises( NPlusOneError, match=re.escape("N+1 detected on Post.author") ): for post in Post.objects.all(): _ = post.author.username for post in Post.objects.select_related("author").all(): _ = post.author.username def test_detects_nplusone_in_forward_many_to_one_iterator(): for _ in range(4): user = UserFactory.create() PostFactory.create(author=user) with pytest.raises( NPlusOneError, match=re.escape("N+1 detected on Post.author") ): for post in Post.objects.all().iterator(chunk_size=2): _ = post.author.username for post in Post.objects.select_related("author").iterator(chunk_size=2): _ = post.author.username def test_handles_prefetch_instead_of_select_related_in_forward_many_to_one(): user_1, user_2 = UserFactory.create_batch(2) PostFactory(author=user_1) PostFactory(author=user_2) with CaptureQueriesContext(connection) as ctx: # this should be a select_related! but we need to handle it even if someone # has accidentally used the wrong method. for post in Post.objects.prefetch_related("author").all(): _ = post.author.username assert len(ctx.captured_queries) == 2 def test_no_false_positive_when_loading_single_object_forward_many_to_one(): user = UserFactory.create() post_1, post_2 = PostFactory.create_batch(2, author=user) with zeal_context(), CaptureQueriesContext(connection) as ctx: post_1 = Post.objects.filter(pk=post_1.pk).first() post_2 = Post.objects.filter(pk=post_2.pk).first() assert post_1 is not None and post_2 is not None # queries on `post` should not raise an exception, because `post` was # singly-loaded _ = post_1.author _ = post_2.author assert len(ctx.captured_queries) == 4 with zeal_context(), CaptureQueriesContext(connection) as ctx: # same when using a slice to get a single record post_1 = Post.objects.filter(pk=post_1.pk).all()[0] post_2 = Post.objects.filter(pk=post_2.pk).all()[0] _ = post_1.author _ = post_2.author assert len(ctx.captured_queries) == 4 with zeal_context(), CaptureQueriesContext(connection) as ctx: # similarly, when using `.get()`, no N+1 error post_1 = Post.objects.get(pk=post_1.pk) post_2 = Post.objects.get(pk=post_2.pk) _ = post_1.author _ = post_2.author assert len(ctx.captured_queries) == 4 def test_detects_nplusone_in_reverse_many_to_one(): [user_1, user_2] = UserFactory.create_batch(2) PostFactory.create(author=user_1) PostFactory.create(author=user_2) with pytest.raises( NPlusOneError, match=re.escape("N+1 detected on User.posts") ): for user in User.objects.all(): _ = list(user.posts.all()) for user in User.objects.prefetch_related("posts").all(): _ = list(user.posts.all()) def test_detects_nplusone_in_reverse_many_to_one_iterator(): for _ in range(4): user = UserFactory.create() PostFactory.create(author=user) with pytest.raises( NPlusOneError, match=re.escape("N+1 detected on User.posts") ): for user in User.objects.all().iterator(chunk_size=2): _ = list(user.posts.all()) for user in User.objects.prefetch_related("posts").iterator(chunk_size=2): _ = list(user.posts.all()) def test_no_false_positive_when_calling_reverse_many_to_one_twice(): user = UserFactory.create() PostFactory.create(author=user) with zeal_context(), CaptureQueriesContext(connection) as ctx: queryset = user.posts.all() list(queryset) # evaluate queryset once list(queryset) # evalute again (cached) assert len(ctx.captured_queries) == 1 def test_detects_nplusone_in_forward_one_to_one(): [user_1, user_2] = UserFactory.create_batch(2) ProfileFactory.create(user=user_1) ProfileFactory.create(user=user_2) with pytest.raises( NPlusOneError, match=re.escape("N+1 detected on Profile.user") ): for profile in Profile.objects.all(): _ = profile.user.username for profile in Profile.objects.select_related("user").all(): _ = profile.user.username def test_detects_nplusone_in_forward_one_to_one_iterator(): for _ in range(4): user = UserFactory.create() ProfileFactory.create(user=user) with pytest.raises( NPlusOneError, match=re.escape("N+1 detected on Profile.user") ): for profile in Profile.objects.all().iterator(chunk_size=2): _ = profile.user.username for profile in Profile.objects.select_related("user").iterator( chunk_size=2 ): _ = profile.user.username def test_handles_prefetch_instead_of_select_related_in_forward_one_to_one(): user_1, user_2 = UserFactory.create_batch(2) ProfileFactory.create(user=user_1) ProfileFactory.create(user=user_2) with CaptureQueriesContext(connection) as ctx: # this should be a select_related! but we need to handle it even if someone # has accidentally used the wrong method. for profile in Profile.objects.prefetch_related("user").all(): _ = profile.user.username assert len(ctx.captured_queries) == 2 def test_no_false_positive_when_loading_single_object_forward_one_to_one(): user_1, user_2 = UserFactory.create_batch(2) profile_1 = ProfileFactory.create(user=user_1) profile_2 = ProfileFactory.create(user=user_2) with zeal_context(), CaptureQueriesContext(connection) as ctx: profile_1 = Profile.objects.filter(pk=profile_1.pk).first() profile_2 = Profile.objects.filter(pk=profile_2.pk).first() assert profile_1 is not None and profile_2 is not None _ = profile_1.user.username _ = profile_2.user.username assert len(ctx.captured_queries) == 4 with zeal_context(), CaptureQueriesContext(connection) as ctx: profile_1 = Profile.objects.filter(pk=profile_1.pk)[0] profile_2 = Profile.objects.filter(pk=profile_2.pk)[0] _ = profile_1.user.username _ = profile_2.user.username assert len(ctx.captured_queries) == 4 with zeal_context(), CaptureQueriesContext(connection) as ctx: profile_1 = Profile.objects.get(pk=profile_1.pk) profile_2 = Profile.objects.get(pk=profile_2.pk) _ = profile_1.user.username _ = profile_2.user.username assert len(ctx.captured_queries) == 4 def test_detects_nplusone_in_reverse_one_to_one(): [user_1, user_2] = UserFactory.create_batch(2) ProfileFactory.create(user=user_1) ProfileFactory.create(user=user_2) with pytest.raises( NPlusOneError, match=re.escape("N+1 detected on User.profile") ): for user in User.objects.all(): _ = user.profile.display_name for user in User.objects.select_related("profile").all(): _ = user.profile.display_name def test_detects_nplusone_in_reverse_one_to_one_iterator(): for _ in range(4): user = UserFactory.create() ProfileFactory.create(user=user) with pytest.raises( NPlusOneError, match=re.escape("N+1 detected on User.profile") ): for user in User.objects.all().iterator(chunk_size=2): _ = user.profile.display_name for user in User.objects.select_related("profile").iterator(chunk_size=2): _ = user.profile.display_name def test_handles_prefetch_instead_of_select_related_in_reverse_one_to_one(): [user_1, user_2] = UserFactory.create_batch(2) ProfileFactory.create(user=user_1) ProfileFactory.create(user=user_2) with CaptureQueriesContext(connection) as ctx: # this should be a select_related! but we need to handle it even if someone # has accidentally used the wrong method. for user in User.objects.prefetch_related("profile").all(): _ = user.profile.display_name assert len(ctx.captured_queries) == 2 def test_no_false_positive_when_loading_single_object_reverse_one_to_one(): user_1, user_2 = UserFactory.create_batch(2) ProfileFactory.create(user=user_1) ProfileFactory.create(user=user_2) with zeal_context(), CaptureQueriesContext(connection) as ctx: user_1 = User.objects.filter(pk=user_1.pk).first() user_2 = User.objects.filter(pk=user_2.pk).first() assert user_1 is not None and user_2 is not None _ = user_1.profile.display_name _ = user_2.profile.display_name assert len(ctx.captured_queries) == 4 with zeal_context(), CaptureQueriesContext(connection) as ctx: user_1 = User.objects.filter(pk=user_1.pk)[0] user_2 = User.objects.filter(pk=user_2.pk)[0] _ = user_1.profile.display_name _ = user_2.profile.display_name assert len(ctx.captured_queries) == 4 with zeal_context(), CaptureQueriesContext(connection) as ctx: user_1 = User.objects.get(pk=user_1.pk) user_2 = User.objects.get(pk=user_2.pk) _ = user_1.profile.display_name _ = user_2.profile.display_name assert len(ctx.captured_queries) == 4 def test_detects_nplusone_in_forward_many_to_many(): [user_1, user_2] = UserFactory.create_batch(2) user_1.following.add(user_2) user_2.following.add(user_1) with pytest.raises( NPlusOneError, match=re.escape("N+1 detected on User.following") ): for user in User.objects.all(): _ = list(user.following.all()) for user in User.objects.prefetch_related("following").all(): _ = list(user.following.all()) def test_detects_nplusone_in_forward_many_to_many_iterator(): influencer = UserFactory.create() users = UserFactory.create_batch(4) influencer.followers.set(users) # type: ignore with pytest.raises( NPlusOneError, match=re.escape("N+1 detected on User.following") ): for user in User.objects.iterator(chunk_size=2): _ = list(user.following.all()) for user in User.objects.prefetch_related("following").iterator( chunk_size=2 ): _ = list(user.following.all()) def test_no_false_positive_when_loading_single_object_forward_many_to_many(): user_1, user_2 = UserFactory.create_batch(2) user_1.following.add(user_2) user_2.following.add(user_1) with zeal_context(), CaptureQueriesContext(connection) as ctx: _ = user_1.following.first().username _ = user_2.following.first().username assert len(ctx.captured_queries) == 2 with zeal_context(), CaptureQueriesContext(connection) as ctx: _ = user_1.following.all()[0].username _ = user_2.following.all()[0].username assert len(ctx.captured_queries) == 2 with zeal_context(), CaptureQueriesContext(connection) as ctx: _ = user_1.following.get(pk=user_2.pk).username _ = user_2.following.get(pk=user_1.pk).username assert len(ctx.captured_queries) == 2 def test_detects_nplusone_in_reverse_many_to_many(): [user_1, user_2] = UserFactory.create_batch(2) user_1.following.add(user_2) user_2.following.add(user_1) with pytest.raises( NPlusOneError, match=re.escape("N+1 detected on User.followers") ): for user in User.objects.all(): _ = list(user.followers.all()) for user in User.objects.prefetch_related("followers").all(): _ = list(user.followers.all()) def test_detects_nplusone_in_reverse_many_to_many_iterator(): follower = UserFactory.create() users = UserFactory.create_batch(4) follower.following.set(users) # type: ignore with pytest.raises( NPlusOneError, match=re.escape("N+1 detected on User.followers") ): for user in User.objects.all().iterator(chunk_size=2): _ = list(user.followers.all()) for user in ( User.objects.prefetch_related("followers").all().iterator(chunk_size=2) ): _ = list(user.followers.all()) def test_no_false_positive_when_loading_single_object_reverse_many_to_many(): user_1, user_2 = UserFactory.create_batch(2) user_1.following.add(user_2) user_2.following.add(user_1) with zeal_context(), CaptureQueriesContext(connection) as ctx: _ = user_1.followers.first().username _ = user_2.followers.first().username assert len(ctx.captured_queries) == 2 with zeal_context(), CaptureQueriesContext(connection) as ctx: _ = user_1.followers.all()[0].username _ = user_2.followers.all()[0].username assert len(ctx.captured_queries) == 2 with zeal_context(), CaptureQueriesContext(connection) as ctx: _ = user_1.followers.get(pk=user_2.pk).username _ = user_2.followers.get(pk=user_1.pk).username assert len(ctx.captured_queries) == 2 def test_detects_nplusone_in_reverse_many_to_many_with_no_related_name(): [user_1, user_2] = UserFactory.create_batch(2) user_1.blocked.add(user_2) user_2.blocked.add(user_1) with pytest.raises( NPlusOneError, match=re.escape("N+1 detected on User.user_set") ): for user in User.objects.all(): _ = list(user.user_set.all()) for user in User.objects.prefetch_related("user_set").all(): _ = list(user.user_set.all()) def test_detects_nplusone_due_to_deferred_fields(): [user_1, user_2] = UserFactory.create_batch(2) PostFactory.create(author=user_1) PostFactory.create(author=user_2) with pytest.raises( NPlusOneError, match=re.escape("N+1 detected on User.username") ): for post in ( Post.objects.all().select_related("author").only("author__id") ): _ = post.author.username for post in ( Post.objects.all().select_related("author").only("author__username") ): _ = post.author.username def test_detects_nplusone_due_to_deferred_fields_in_iterator(): for _ in range(4): user = UserFactory.create() PostFactory.create(author=user) with pytest.raises( NPlusOneError, match=re.escape("N+1 detected on User.username") ): for post in ( Post.objects.all() .select_related("author") .only("author__id") .iterator(chunk_size=2) ): _ = post.author.username for post in ( Post.objects.all() .select_related("author") .only("author__username") .iterator(chunk_size=2) ): _ = post.author.username def test_handles_prefetch_instead_of_select_related_with_deferred_fields(): [user_1, user_2] = UserFactory.create_batch(2) PostFactory.create(author=user_1) PostFactory.create(author=user_2) with CaptureQueriesContext(connection) as ctx: # this should be a select_related! but we need to handle it even if someone # has accidentally used the wrong method. for post in ( Post.objects.all() .prefetch_related("author") .only("author__username") ): _ = post.author.username assert len(ctx.captured_queries) == 2 def test_has_configurable_threshold(settings): settings.ZEAL_NPLUSONE_THRESHOLD = 3 [user_1, user_2] = UserFactory.create_batch(2) PostFactory.create(author=user_1) PostFactory.create(author=user_2) for post in Post.objects.all(): _ = post.author.username @zeal_ignore() def test_does_nothing_if_not_in_middleware(settings, client): settings.MIDDLEWARE = [] [user_1, user_2] = UserFactory.create_batch(2) ProfileFactory.create(user=user_1) ProfileFactory.create(user=user_2) # this does not raise an N+1 error even though the same # related field gets hit twice response = client.get(f"/user/{user_1.pk}/") assert response.status_code == 200 response = client.get(f"/user/{user_2.pk}/") assert response.status_code == 200 def test_works_in_web_requests(client): [user_1, user_2] = UserFactory.create_batch(2) ProfileFactory.create(user=user_1) ProfileFactory.create(user=user_2) with pytest.raises(NPlusOneError): response = client.get("/users/") # but multiple requests work fine response = client.get(f"/user/{user_1.pk}/") assert response.status_code == 200 response = client.get(f"/user/{user_2.pk}/") assert response.status_code == 200 def test_ignores_calls_on_different_lines(): [user_1, user_2] = UserFactory.create_batch(2) PostFactory.create(author=user_1) PostFactory.create(author=user_2) # this should *not* raise an exception _a = list(user_1.posts.all()) _b = list(user_2.posts.all()) django-zeal-1.3.0/tests/test_patch.py000066400000000000000000000011471465047714100175740ustar00rootroot00000000000000import sys import pytest from djangoproject.social.models import User from tests.factories import UserFactory pytestmark = pytest.mark.django_db def test_handles_calling_queryset_many_times(): UserFactory.create() user = User.objects.prefetch_related("posts").all()[0] for _ in range(sys.getrecursionlimit() + 1): # this should *not* raise a recursion error list(user.posts.all()) def test_handles_empty_querysets(): User.objects.none().first() def test_handles_get_with_values(): user = UserFactory.create() User.objects.filter(pk=user.pk).values("username").get()