├── .bumpversion.cfg ├── .coveragerc ├── .git-blame-ignore-revs ├── .github └── workflows │ └── build.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── AUTHORS.rst ├── CHANGELOG.rst ├── CONTRIBUTING.rst ├── LICENSE ├── MANIFEST.in ├── README.rst ├── ci ├── bootstrap.py └── templates │ └── tox.ini ├── docs ├── authors.rst ├── changelog.rst ├── conf.py ├── contributing.rst ├── descriptors_usage.rst ├── getting_started.rst ├── identity_map.rst ├── identity_map_comparison.rst ├── identity_map_usage.rst ├── index.rst ├── reference │ ├── descriptors.rst │ ├── identity_map.rst │ ├── index.rst │ └── selector.rst ├── requirements.txt ├── spelling_wordlist.txt └── usage.rst ├── pyproject.toml ├── setup.cfg ├── setup.py ├── src └── django_prefetch_utils │ ├── __init__.py │ ├── apps.py │ ├── backport.py │ ├── descriptors │ ├── __init__.py │ ├── annotation.py │ ├── base.py │ ├── equal_fields.py │ ├── top_child.py │ └── via_lookup.py │ ├── identity_map │ ├── __init__.py │ ├── maps.py │ ├── persistent.py │ └── wrappers.py │ └── selector.py ├── tests ├── apps_tests.py ├── backport_tests.py ├── descriptors_tests │ ├── __init__.py │ ├── annotation_tests.py │ ├── equal_fields_tests.py │ ├── mixins.py │ ├── models.py │ ├── top_child_tests.py │ └── via_lookup_tests.py ├── foreign_object │ ├── __init__.py │ ├── models │ │ ├── __init__.py │ │ ├── article.py │ │ ├── customers.py │ │ ├── empty_join.py │ │ └── person.py │ ├── test_agnostic_order_trimjoin.py │ ├── test_empty_join.py │ ├── test_forms.py │ └── tests.py ├── identity_map │ ├── __init__.py │ ├── django_tests.py │ ├── integration_tests.py │ ├── misc_tests.py │ ├── mixins.py │ └── persistent_tests.py ├── prefetch_related │ ├── __init__.py │ ├── models.py │ ├── test_prefetch_related_objects.py │ ├── test_uuid.py │ └── tests.py ├── pyenv_markers.py ├── selector_tests.py └── settings.py └── tox.ini /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.2.0 3 | commit = True 4 | 5 | [bumpversion:file:setup.py] 6 | search = version="{current_version}" 7 | replace = version="{new_version}" 8 | 9 | [bumpversion:file:README.rst] 10 | search = v{current_version}. 11 | replace = v{new_version}. 12 | 13 | [bumpversion:file:docs/conf.py] 14 | search = version = release = "{current_version}" 15 | replace = version = release = "{new_version}" 16 | 17 | [bumpversion:file:src/django_prefetch_utils/__init__.py] 18 | search = __version__ = "{current_version}" 19 | replace = __version__ = "{new_version}" 20 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [paths] 2 | source = src 3 | 4 | [run] 5 | branch = true 6 | source = 7 | src 8 | tests 9 | parallel = true 10 | omit = 11 | tests/settings.py 12 | tests/foreign_object/* 13 | tests/prefetch_related/* 14 | 15 | [report] 16 | show_missing = true 17 | precision = 2 18 | omit = *migrations* 19 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # Run code through black + formatting fixes 2 | e41de523b68dd427622d26b47052f78fa0d32484 3 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Python package 2 | 3 | on: 4 | - pull_request 5 | 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | python-version: ['3.7', '3.8', '3.9', '3.10'] 12 | 13 | steps: 14 | - uses: actions/checkout@v1 15 | - name: Set up Python ${{ matrix.python-version }} 16 | uses: actions/setup-python@v2 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | - name: Install dependencies 20 | run: | 21 | python -m pip install --upgrade pip 22 | python -m pip install tox tox-gh-actions 23 | - name: Test with tox 24 | run: tox 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | .eggs 13 | parts 14 | bin 15 | var 16 | sdist 17 | wheelhouse 18 | develop-eggs 19 | .installed.cfg 20 | lib 21 | lib64 22 | venv*/ 23 | pyvenv*/ 24 | 25 | # Installer logs 26 | pip-log.txt 27 | 28 | # Unit test / coverage reports 29 | .coverage 30 | .tox 31 | .coverage.* 32 | nosetests.xml 33 | coverage.xml 34 | htmlcov 35 | .pytest_cache 36 | 37 | # Translations 38 | *.mo 39 | 40 | # Mr Developer 41 | .mr.developer.cfg 42 | .project 43 | .pydevproject 44 | .idea 45 | *.iml 46 | *.komodoproject 47 | 48 | # Complexity 49 | output/*.html 50 | output/*/index.html 51 | 52 | # Sphinx 53 | docs/_build 54 | 55 | .DS_Store 56 | *~ 57 | .*.sw[po] 58 | .build 59 | .ve 60 | .env 61 | .cache 62 | .pytest 63 | .bootstrap 64 | .appveyor.token 65 | *.bak 66 | .python-version 67 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v2.3.0 4 | hooks: 5 | - id: check-yaml 6 | - id: end-of-file-fixer 7 | - id: trailing-whitespace 8 | - repo: https://github.com/psf/black 9 | rev: 19.3b0 10 | hooks: 11 | - id: black 12 | - repo: https://github.com/pycqa/flake8 13 | rev: '4.0.1' # pick a git hash / tag to point to 14 | hooks: 15 | - id: flake8 16 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | Authors 2 | ======= 3 | 4 | * Mike Hansen - https://github.com/mwhansen/ 5 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | 0.2.0 (2022-01-12) 5 | ------------------ 6 | * Added library of descriptors for defining relationships of Django models 7 | which can be prefetched. 8 | 9 | * Add support for the latest version of Django and Python. 10 | 11 | * Removed support for Python 2 and unsupported Django versions. 12 | 13 | * Updated backport of prefetch_related_objects to latest version from Django 4.0. 14 | 15 | 0.1.0 (2019-07-16) 16 | ------------------ 17 | 18 | * First release on PyPI. 19 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Contributing 3 | ============ 4 | 5 | Contributions are welcome, and they are greatly appreciated! Every 6 | little bit helps, and credit will always be given. 7 | 8 | Bug reports 9 | =========== 10 | 11 | When `reporting a bug `_ please include: 12 | 13 | * Your operating system name and version. 14 | * Any details about your local setup that might be helpful in troubleshooting. 15 | * Detailed steps to reproduce the bug. 16 | 17 | Documentation improvements 18 | ========================== 19 | 20 | `django-prefetch-utils` could always use more documentation, whether as part of the 21 | official `django-prefetch-utils` docs, in docstrings, or even on the web in blog posts, 22 | articles, and such. 23 | 24 | Feature requests and feedback 25 | ============================= 26 | 27 | The best way to send feedback is to file an issue at https://github.com/roverdotcom/django-prefetch-utils/issues. 28 | 29 | If you are proposing a feature: 30 | 31 | * Explain in detail how it would work. 32 | * Keep the scope as narrow as possible, to make it easier to implement. 33 | * Remember that this is a volunteer-driven project, and that code contributions are welcome :) 34 | 35 | Development 36 | =========== 37 | 38 | To set up `django-prefetch-utils` for local development: 39 | 40 | 1. Fork `django-prefetch-utils `_ 41 | (look for the "Fork" button). 42 | 2. Clone your fork locally:: 43 | 44 | git clone git@github.com:your_name_here/django-prefetch-utils.git 45 | 46 | 3. Create a branch for local development:: 47 | 48 | git checkout -b name-of-your-bugfix-or-feature 49 | 50 | Now you can make your changes locally. 51 | 52 | 4. When you're done making changes, run all the checks, doc builder and spell checker with `tox `_ one command:: 53 | 54 | tox 55 | 56 | 5. Commit your changes and push your branch to GitHub:: 57 | 58 | git add . 59 | git commit -m "Your detailed description of your changes." 60 | git push origin name-of-your-bugfix-or-feature 61 | 62 | 6. Submit a pull request through the GitHub website. 63 | 64 | Pull Request Guidelines 65 | ----------------------- 66 | 67 | If you need some code review or feedback while you're developing the code just make the pull request. 68 | 69 | For merging, you should: 70 | 71 | 1. Include passing tests (run ``tox``) [1]_. 72 | 2. Update documentation when there's new API, functionality etc. 73 | 3. Add a note to ``CHANGELOG.rst`` about the changes. 74 | 4. Add yourself to ``AUTHORS.rst``. 75 | 76 | .. [1] If you don't have all the necessary python versions available locally you can rely on Travis - it will 77 | `run the tests `_ for each change you add in the pull request. 78 | 79 | It will be slower though ... 80 | 81 | Tips 82 | ---- 83 | 84 | To run a subset of tests:: 85 | 86 | tox -e envname -- pytest -k test_myfeature 87 | 88 | To run all the test environments in *parallel* (you need to ``pip install detox``):: 89 | 90 | detox 91 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019, Mike Hansen 2 | Copyright (c) Django Software Foundation and individual contributors. 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without modification, 6 | are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, 9 | this list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright 12 | notice, this list of conditions and the following disclaimer in the 13 | documentation and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of Django nor the names of its contributors may be used 16 | to endorse or promote products derived from this software without 17 | specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 20 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 21 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 23 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 24 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 25 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 26 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 27 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 28 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | graft docs 2 | graft src 3 | graft ci 4 | graft tests 5 | 6 | include .bumpversion.cfg 7 | include .coveragerc 8 | include .cookiecutterrc 9 | include .editorconfig 10 | 11 | include AUTHORS.rst 12 | include CHANGELOG.rst 13 | include CONTRIBUTING.rst 14 | include LICENSE 15 | include README.rst 16 | 17 | include tox.ini appveyor.yml 18 | 19 | exclude .git-blame-ignore-revs 20 | exclude .pre-commit-config.yaml 21 | global-exclude *.py[cod] __pycache__ *.so *.dylib 22 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | Overview 3 | ======== 4 | 5 | .. start-badges 6 | 7 | .. list-table:: 8 | :stub-columns: 1 9 | 10 | * - docs 11 | - |docs| 12 | * - tests 13 | - |codecov| 14 | * - package 15 | - | |version| |wheel| |supported-versions| |supported-implementations| 16 | | |commits-since| 17 | 18 | .. |docs| image:: https://readthedocs.org/projects/django-prefetch-utils/badge/?style=flat 19 | :target: https://readthedocs.org/projects/django-prefetch-utils 20 | :alt: Documentation Status 21 | 22 | 23 | .. |codecov| image:: https://codecov.io/github/roverdotcom/django-prefetch-utils/coverage.svg?branch=master 24 | :alt: Coverage Status 25 | :target: https://codecov.io/github/roverdotcom/django-prefetch-utils 26 | 27 | .. |version| image:: https://img.shields.io/pypi/v/django-prefetch-utils.svg 28 | :alt: PyPI Package latest release 29 | :target: https://pypi.org/project/django-prefetch-utils 30 | 31 | .. |commits-since| image:: https://img.shields.io/github/commits-since/roverdotcom/django-prefetch-utils/v0.2.0.svg 32 | :alt: Commits since latest release 33 | :target: https://github.com/roverdotcom/django-prefetch-utils/compare/v0.2.0...master 34 | 35 | .. |wheel| image:: https://img.shields.io/pypi/wheel/django-prefetch-utils.svg 36 | :alt: PyPI Wheel 37 | :target: https://pypi.org/project/django-prefetch-utils 38 | 39 | .. |supported-versions| image:: https://img.shields.io/pypi/pyversions/django-prefetch-utils.svg 40 | :alt: Supported versions 41 | :target: https://pypi.org/project/django-prefetch-utils 42 | 43 | .. |supported-implementations| image:: https://img.shields.io/pypi/implementation/django-prefetch-utils.svg 44 | :alt: Supported implementations 45 | :target: https://pypi.org/project/django-prefetch-utils 46 | 47 | 48 | .. end-badges 49 | 50 | This library provides a number of utilities for working with and extending 51 | Django's ``prefetch_related`` system. Currently, it consists of: 52 | 53 | * a collection of descriptors to define relationships between models which 54 | support prefetching 55 | * a new implementation of ``prefetch_related_objects`` which supports an 56 | identity map so that multiple copies of the same object are not fetched 57 | multiple times. 58 | 59 | * Free software: BSD 3-Clause License 60 | 61 | Installation 62 | ============ 63 | 64 | :: 65 | 66 | pip install django-prefetch-utils 67 | 68 | Documentation 69 | ============= 70 | 71 | 72 | https://django-prefetch-utils.readthedocs.io/ 73 | -------------------------------------------------------------------------------- /ci/bootstrap.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import os 4 | import sys 5 | from os.path import abspath 6 | from os.path import dirname 7 | from os.path import exists 8 | from os.path import join 9 | 10 | 11 | if __name__ == "__main__": 12 | base_path = dirname(dirname(abspath(__file__))) 13 | print("Project path: {0}".format(base_path)) 14 | env_path = join(base_path, ".tox", "bootstrap") 15 | if sys.platform == "win32": 16 | bin_path = join(env_path, "Scripts") 17 | else: 18 | bin_path = join(env_path, "bin") 19 | if not exists(env_path): 20 | import subprocess 21 | 22 | print("Making bootstrap env in: {0} ...".format(env_path)) 23 | try: 24 | subprocess.check_call(["virtualenv", env_path]) 25 | except subprocess.CalledProcessError: 26 | subprocess.check_call([sys.executable, "-m", "virtualenv", env_path]) 27 | print("Installing `jinja2` into bootstrap environment...") 28 | subprocess.check_call([join(bin_path, "pip"), "install", "jinja2"]) 29 | python_executable = join(bin_path, "python") 30 | if not os.path.samefile(python_executable, sys.executable): 31 | print("Re-executing with: {0}".format(python_executable)) 32 | os.execv(python_executable, [python_executable, __file__]) 33 | 34 | import jinja2 35 | 36 | import matrix 37 | 38 | jinja = jinja2.Environment( 39 | loader=jinja2.FileSystemLoader(join(base_path, "ci", "templates")), 40 | trim_blocks=True, 41 | lstrip_blocks=True, 42 | keep_trailing_newline=True, 43 | ) 44 | 45 | tox_environments = {} 46 | for (alias, conf) in matrix.from_file(join(base_path, "setup.cfg")).items(): 47 | python = conf["python_versions"] 48 | deps = conf["dependencies"] 49 | tox_environments[alias] = {"python": "python" + python if "py" not in python else python, "deps": deps.split()} 50 | if "coverage_flags" in conf: 51 | cover = {"false": False, "true": True}[conf["coverage_flags"].lower()] 52 | tox_environments[alias].update(cover=cover) 53 | if "environment_variables" in conf: 54 | env_vars = conf["environment_variables"] 55 | tox_environments[alias].update(env_vars=env_vars.split()) 56 | 57 | for name in os.listdir(join("ci", "templates")): 58 | with open(join(base_path, name), "w") as fh: 59 | fh.write(jinja.get_template(name).render(tox_environments=tox_environments)) 60 | print("Wrote {}".format(name)) 61 | print("DONE.") 62 | -------------------------------------------------------------------------------- /ci/templates/tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | clean, 4 | check, 5 | docs, 6 | {% for env in tox_environments|sort %} 7 | {{ env }}, 8 | {% endfor %} 9 | report 10 | 11 | [gh-actions] 12 | python = 13 | 3.7: 3.7 14 | 3.8: 3.8 15 | 3.9: 3.9 16 | 3.10: 3.10,docs,clean,check 17 | 18 | [testenv] 19 | basepython = 20 | {docs,bootstrap,clean,check,report,codecov}: {env:TOXPYTHON:python3} 21 | setenv = 22 | PYTHONPATH={toxinidir}/tests 23 | PYTHONUNBUFFERED=yes 24 | passenv = 25 | * 26 | deps = 27 | pytest 28 | pytest-django 29 | pytest-travis-fold 30 | future 31 | commands = 32 | {posargs:pytest -vv --ignore=src} 33 | 34 | [testenv:bootstrap] 35 | deps = 36 | jinja2 37 | matrix 38 | skip_install = true 39 | commands = 40 | python ci/bootstrap.py 41 | 42 | [testenv:check] 43 | deps = 44 | docutils 45 | check-manifest 46 | flake8 47 | readme-renderer 48 | pygments 49 | isort 50 | twine 51 | skip_install = true 52 | commands = 53 | python setup.py sdist 54 | twine check dist/*.tar.gz 55 | check-manifest {toxinidir} 56 | flake8 src tests setup.py 57 | isort --verbose --check-only --diff src tests setup.py 58 | 59 | 60 | [testenv:docs] 61 | deps = 62 | -r{toxinidir}/docs/requirements.txt 63 | commands = 64 | sphinx-build {posargs:-E} -b html docs dist/docs 65 | sphinx-build -b linkcheck docs dist/docs 66 | 67 | 68 | [testenv:codecov] 69 | deps = 70 | codecov 71 | skip_install = true 72 | commands = 73 | coverage xml --ignore-errors 74 | codecov [] 75 | 76 | [testenv:report] 77 | deps = coverage 78 | skip_install = true 79 | commands = 80 | coverage report 81 | coverage html 82 | 83 | [testenv:clean] 84 | commands = coverage erase 85 | skip_install = true 86 | deps = coverage 87 | 88 | {% for env, config in tox_environments|dictsort %} 89 | [testenv:{{ env }}] 90 | basepython = {env:TOXPYTHON:{{ config.python }}} 91 | {% if config.cover or config.env_vars %} 92 | setenv = 93 | {[testenv]setenv} 94 | {% endif %} 95 | {% for var in config.env_vars %} 96 | {{ var }} 97 | {% endfor %} 98 | {% if config.cover %} 99 | usedevelop = true 100 | commands = 101 | {posargs:pytest --cov --cov-report=term-missing -vv} 102 | {% endif %} 103 | {% if config.cover or config.deps %} 104 | deps = 105 | {[testenv]deps} 106 | {% endif %} 107 | {% if config.cover %} 108 | pytest-cov 109 | {% endif %} 110 | {% for dep in config.deps %} 111 | {{ dep }} 112 | {% endfor %} 113 | 114 | {% endfor %} 115 | -------------------------------------------------------------------------------- /docs/authors.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../AUTHORS.rst 2 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CHANGELOG.rst 2 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | import sys 4 | 5 | import django 6 | 7 | sys.path.insert(0, os.path.abspath("../tests/")) 8 | os.environ["DJANGO_SETTINGS_MODULE"] = "settings" 9 | django.setup() 10 | 11 | 12 | extensions = [ 13 | "sphinx.ext.autodoc", 14 | "sphinx.ext.autosummary", 15 | "sphinx.ext.coverage", 16 | "sphinx.ext.doctest", 17 | "sphinx.ext.extlinks", 18 | "sphinx.ext.ifconfig", 19 | "sphinx.ext.napoleon", 20 | "sphinx.ext.todo", 21 | "sphinx.ext.viewcode", 22 | ] 23 | if os.getenv("SPELLCHECK"): 24 | extensions += ("sphinxcontrib.spelling",) 25 | spelling_show_suggestions = True 26 | spelling_lang = "en_US" 27 | 28 | source_suffix = ".rst" 29 | master_doc = "index" 30 | project = "Django Prefetch Utils" 31 | year = "2019" 32 | author = "Mike Hansen" 33 | copyright = "{0}, {1}".format(year, author) 34 | version = release = "0.2.0" 35 | 36 | pygments_style = "trac" 37 | templates_path = ["."] 38 | extlinks = { 39 | "issue": ("https://github.com/roverdotcom/django-prefetch-utils/issues/%s", "#"), 40 | "pr": ("https://github.com/roverdotcom/django-prefetch-utils/pull/%s", "PR #"), 41 | } 42 | # on_rtd is whether we are on readthedocs.org 43 | on_rtd = os.environ.get("READTHEDOCS", None) == "True" 44 | 45 | if not on_rtd: # only set the theme if we're building docs locally 46 | html_theme = "sphinx_rtd_theme" 47 | 48 | html_use_smartypants = True 49 | html_last_updated_fmt = "%b %d, %Y" 50 | html_split_index = False 51 | html_sidebars = {"**": ["searchbox.html", "globaltoc.html", "sourcelink.html"]} 52 | html_short_title = "%s-%s" % (project, version) 53 | 54 | napoleon_use_ivar = True 55 | napoleon_use_rtype = False 56 | napoleon_use_param = False 57 | 58 | add_module_names = False 59 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /docs/descriptors_usage.rst: -------------------------------------------------------------------------------- 1 | =========== 2 | Descriptors 3 | =========== 4 | 5 | This library provides a number of classes which allow the user to 6 | define relationships between models that Django does not provide 7 | support for out of the box. Importantly, all of the related objects 8 | are able to be prefetched for these relationships. 9 | 10 | We'll take a look at the descriptors provided. 11 | 12 | Basic descriptors 13 | ----------------- 14 | 15 | One of the simplest uses of these descriptors is when the relationship 16 | between the objects can be specified by a Django "lookup" which gives 17 | the path from the model we want to prefetch to the model where we're 18 | adding the descriptor:: 19 | 20 | 21 | class Author(models.Model): 22 | class Meta: 23 | ordering = ('name',) 24 | name = models.CharField(max_length=128) 25 | 26 | class Book(models.Model): 27 | authors = models.ManyToManyField( 28 | Author, 29 | models.CASCADE, 30 | related_name='books' 31 | ) 32 | 33 | class Reader(models.Model): 34 | books_read = models.ManyToManyField(Book, related_name='read_by') 35 | authors_read = RelatedQuerySetDescriptorViaLookup( 36 | Author, 37 | 'books__read_by' 38 | ) 39 | 40 | 41 | which allows us to do:: 42 | 43 | >>> reader = Reader.objects.prefetch_related('authors_read').first() 44 | >>> len({author.name for author in reader.authors_read.all()}) # no queries done 45 | 10 46 | 47 | 48 | In the case where there's just a single related object, we can use 49 | :class:`~django_prefetch_utils.descriptors.via_lookup.RelatedSingleObjectDescriptorViaLookup` 50 | instead:: 51 | 52 | class Reader(models.Model): 53 | ... 54 | first_author_read = RelatedSingleObjectDescriptorViaLookup( 55 | Author, 56 | 'books__read_by' 57 | ) 58 | 59 | This allows us to do:: 60 | 61 | >> reader = Reader.objects.prefetch_related('first_author_read').first() 62 | >> reader.first_author_read.name # no queries done 63 | 'Aaron Adams' 64 | 65 | 66 | These can also come in useful to define relationships that span 67 | databases. For example, suppose we were to store our ``Dog`` and 68 | ``Toy`` models in separate databases. We can add a descriptor to 69 | ``Dog.toys`` to get the behavior as if ``Toy.dog_id`` had been a 70 | ``ForeignKey``:: 71 | 72 | class Toy(models.Model): 73 | # Stored in database #1. We can't use a ForeignKey to Dog 74 | # since the table for that model is in a separate database. 75 | dog_id = models.PositiveIntegerField() 76 | name = models.CharField(max_length=32) 77 | 78 | class Dog(models.Model): 79 | # Stored in database #2 80 | name = models.CharField(max_length=32) 81 | 82 | # We can use a descriptor to get the same behavior as if 83 | # we had the reverse relationship from a ForeignKey 84 | toys = RelatedQuerySetDescriptorViaLookup(Toy, 'dog_id') 85 | 86 | 87 | Equal fields 88 | ------------ 89 | 90 | We sometimes have relationships between models which are necessarily defined 91 | by foreign key relationships. For example, consider the case where we have 92 | models for people and books, and they both have a column corresponding to a 93 | year:: 94 | 95 | class Book(models.Model): 96 | published_year = models.IntegerField() 97 | 98 | class Person(models.Model): 99 | birth_year = models.IntegerField() 100 | 101 | 102 | If we want to efficiently get all of the books published in the same year 103 | that the person is born, we can use the 104 | :class:`~django_prefetch_utils.descriptors.equal_fields.EqualFieldsDescriptor` to define 105 | that relationship:: 106 | 107 | class Person(models.Model): 108 | birth_year = models.IntegerField() 109 | books_from_birth_year = EqualFieldsDescriptor( 110 | Book, 111 | [('birth_year', 'published_year')] 112 | ) 113 | 114 | Then we're able to do things like:: 115 | 116 | >>> person = Person.objects.prefetch_related('books_from_birth_year').first() 117 | >>> Person.books_from_birth_year.count() # no queries are done 118 | 3 119 | 120 | 121 | Top child descriptor 122 | -------------------- 123 | 124 | In a situation with a one-to-many relationship (think parent / child), 125 | we are often interested in the first child under some ordering. For 126 | example, let's say we had a message thread (the parent) with many 127 | messages (the children) and we want to be able to efficiently fetch 128 | the most recent message. Then, we can do that with 129 | :class:`~django_prefetch_utils.descriptors.top_child.TopChildDescriptorFromField`:: 130 | 131 | class MessageThread(models.Model): 132 | most_recent_message = TopChildDescriptorFromField( 133 | 'my_app.Message.thread', 134 | order_by=('-added',) 135 | ) 136 | 137 | class Message(models.Model): 138 | added = models.DateTimeField(auto_now_add=True, db_index=True) 139 | thread = models.ForeignKey(MessageThread, on_deleted=models.PROTECT) 140 | text = models.TextField() 141 | 142 | Then, we're able to do things like:: 143 | 144 | >>> thread = MessageThread.objects.prefetch_related('most_recent_message').first() 145 | >>> thread.most_recent_message.text # no queries are done 146 | 'Talk to you later!' 147 | 148 | 149 | If the one-to-many relationship is given by a generic foreign key, 150 | then we can use 151 | :class:`~django_prefetch_utils.descriptors.top_child.TopChildDescriptorFromGenericRelation` 152 | instead. 153 | 154 | 155 | Annotated Values 156 | ---------------- 157 | 158 | In addition to being able to prefetch models, we can use the 159 | :class:`~django_prefetch_utils.descriptors.annotation.AnnotationDescriptor` to 160 | prefetch values defined by an annotation on a queryset. 161 | 162 | For example, let's say we're say interested in computing the number of 163 | 164 | in a value which can be computed as 165 | an annotation on a queryset, but we'll also want to be able access 166 | that same value on a model even if that model did not come from a 167 | queryset which included that annotation:: 168 | 169 | from django.db import models 170 | from django_prefetch_utils.descriptors import AnnotationDescriptor 171 | 172 | class Toy(models.Model): 173 | dog = models.ForeignKey('dogs.Dog') 174 | name = models.CharField(max_length=32) 175 | 176 | class Dog(models.Model): 177 | name = models.CharField(max_length=32) 178 | toy_count = AnnotationDescriptor(models.Count('toy_set')) 179 | 180 | :: 181 | 182 | >>> dog = Dog.objects.first() 183 | >>> dog.toy_count 184 | 11 185 | >>> dog = Dog.objects.prefetch_related('toy_count').first() 186 | >>> dog.toy_count # no queries are done 187 | 11 188 | 189 | 190 | See :class:`~django_prefetch_utils.descriptors.annotation.AnnotationDescriptor` 191 | for more information. 192 | 193 | 194 | Generic base classes 195 | -------------------- 196 | 197 | If the functionality of the above classes isn't enough, then we can 198 | make use of the generic base classes to easily define custom 199 | desciptors which support 200 | prefetching. :class:`~django_prefetch_utils.descriptors.base.GenericPrefetchRelatedDescriptor` 201 | is the abstract base class which we need to subclass. It has a number 202 | of abstract methods which need to be implemented: 203 | 204 | * :meth:`get_prefetch_model_class`: this needs to return the model 205 | class for the objects which are being prefetched. 206 | * :meth:`filter_queryset_for_instances`: this takes in a *queryset* 207 | for the models to be prefetched along with *instances* of the 208 | model on which the descriptor is found; it needs to return that 209 | *queryset* filtered to the objects which are related to the 210 | provided *instances*. 211 | * :meth:`get_join_for_instance`: this takes in an *instance* of the 212 | model on which the descriptor is found and returns a value used 213 | match it up with the prefetched objects. 214 | * :meth:`get_join_value_for_related_obj`: this takes in a 215 | prefetched object and returns a value used to match it up with 216 | the *instances* of the original model. 217 | 218 | If we're only interested in a single object, then we can include 219 | :class:`~django_prefetch_utils.descriptors.base.GenericSinglePrefetchRelatedDescriptorMixin` 220 | into our class. This will make it so that when we access the 221 | descriptor, we get the the object directly rather than a manager. 222 | -------------------------------------------------------------------------------- /docs/getting_started.rst: -------------------------------------------------------------------------------- 1 | =============== 2 | Getting Started 3 | =============== 4 | 5 | Install using pip:: 6 | 7 | pip install django-prefetch-utils 8 | 9 | Add ``django_prefetch_utils`` to your ``INSTALLED_APPS`` setting:: 10 | 11 | INSTALLED_APPS = [ 12 | "django_prefetch_utils", 13 | ... 14 | ] 15 | 16 | To use the `identity map 17 | `_ 18 | ``prefetch_related_objects`` implementation globally, provide the 19 | ``PREFETCH_UTILS_DEFAULT_IMPLEMENTATION`` setting:: 20 | 21 | PREFETCH_UTILS_DEFAULT_IMPLEMENTATION = ( 22 | 'django_prefetch_utils.identity_map.prefetch_related_objects' 23 | ) 24 | 25 | See :doc:`identity_map_usage` for more ways to use this library. 26 | -------------------------------------------------------------------------------- /docs/identity_map.rst: -------------------------------------------------------------------------------- 1 | Identity map 2 | ------------ 3 | 4 | This library currently provides a replacement implementation of 5 | ``prefetch_related_objects`` which uses an `identity map 6 | `_ to 7 | automatically reduce the number of queries performed when prefetching. 8 | 9 | For example, considered the following data model:: 10 | 11 | class Toy(models.Model): 12 | dog = models.ForeignKey('dogs.Dog') 13 | 14 | class Dog(models.Model): 15 | name = models.CharField() 16 | favorite_toy = models.ForeignKey('toys.Toy', null=True) 17 | 18 | 19 | With this library, we get don't need to do a database query to 20 | perform the prefetch for ``favorite_toy`` since that object 21 | had already been fetched as part of the prefetching for ``toy_set``:: 22 | 23 | >>> dog = Dog.objects.prefetch_related('toys', 'favorite_toy')[0] 24 | SELECT * from dogs_dog limit 1; 25 | SELECT * FROM toys_toy where toys_toy.dog_id IN (1); 26 | >>> dog.favorite_toy is dog.toy_set.all()[0] # no queries done 27 | True 28 | 29 | .. toctree:: 30 | :maxdepth: 2 31 | :hidden: 32 | 33 | identity_map_usage 34 | identity_map_comparison 35 | -------------------------------------------------------------------------------- /docs/identity_map_comparison.rst: -------------------------------------------------------------------------------- 1 | ====================================== 2 | Comparison with default implementation 3 | ====================================== 4 | 5 | The 6 | :func:`django_prefetch_utils.identity_map.prefetch_related_objects` 7 | implementation provides a number of benefits over Django's default 8 | implementation. 9 | 10 | 11 | Database query reduction 12 | ------------------------ 13 | 14 | One benefit of Django's ``prefetch_related`` system vs. ``select_related`` is 15 | that for the same prefetch lookup, equal model instances are identical. 16 | For example:: 17 | 18 | >>> toy1, toy2 = Toy.objects.prefetch_related("dog") 19 | >>> toy1.dog == toy2.dog 20 | True 21 | >>> toy1.dog is toy2.dog 22 | True 23 | >>> toy1, toy2 = Toy.objects.select_related("dog") 24 | >>> toy1.dog is toy2.dog 25 | False 26 | 27 | If for example, there is a ``cached_property`` on a the ``Dog`` model, then 28 | that would end up being shared by both ``Toy`` instances. 29 | 30 | Now, consider a model like:: 31 | 32 | class Dog(models.Model): 33 | toys = models.ManyToManyField(Toy) 34 | favorite_toy = models.ForeignKey(Toy, null=True) 35 | 36 | If we prefetch the toys and favorite toy, there will be two ``Toy`` 37 | objects which are equal but not identical. We get the following behavior 38 | with Django's default implementation:: 39 | 40 | >>> dog = Dog.objects.prefetch_related("toys", "favorite_toy")[0] 41 | >>> only_toy = dog.toys.all()[0] 42 | >>> only_toy == dog.favorite_toy 43 | True 44 | >>> only_toy is dog.favorite_toy 45 | False 46 | 47 | The identity map implementation keeps track of all of the objects fetched 48 | during the the process so that it can reuse them when possible . If we 49 | were to run the same code as above with the identity map implementation, 50 | we would have:: 51 | 52 | >>> only_toy is dog.favorite_toy 53 | True 54 | 55 | Additionally, since ``favorite_toy`` was already fetched when ``toys`` was 56 | prefetched, **less database queries are done**. The same code is 57 | executed with 2 database queries instead of 3. 58 | 59 | Prefetch composition 60 | -------------------- 61 | 62 | One consequence of Django's default implementation of ``prefetch_related`` is 63 | that there are cases where it will silently not perform a requested prefetch. 64 | For example:: 65 | 66 | >>> toy_qs = Toy.objects.prefetch_related( 67 | ... Prefetch("dog", queryset=Dog.objects.prefetch_related("owner")) 68 | ... ) 69 | >>> dog = Dog.objects.prefetch_related( 70 | ... Prefetch("toy_set", queryset=toy_qs) 71 | ... )[0] 72 | >>> toy = dog.toy_set.all()[0] 73 | >>> toy.dog is dog 74 | True 75 | 76 | If we access ``dog.owner``, then a database query is done even though 77 | it looks like we requested that it be prefetched. This happens 78 | because when the ``dog`` object is already set by the reverse relation 79 | when ``toy_set__dog`` is prefetched. Therefore, the 80 | ``Dog.objects.prefetch_related("owner")`` queryset is never taken into 81 | account. This makes it difficult programmatically compose querysets 82 | with prefetches inside other ``Prefetch`` objects. 83 | 84 | :func:`django_prefetch_utils.identity_map.prefetch_related_objects` is 85 | implemented in a way does not ignore prefetches in cases like the above. 86 | -------------------------------------------------------------------------------- /docs/identity_map_usage.rst: -------------------------------------------------------------------------------- 1 | ================== 2 | Identity Map Usage 3 | ================== 4 | 5 | The 6 | :func:`django_prefetch_utils.identity_map.prefetch_related_objects` 7 | implementation use an `identity map 8 | `_ to provide a 9 | number of benefits over Django's default. See 10 | :doc:`./identity_map_comparison` for a discussion of the 11 | improvements. It should be a drop-in replacement, requiring no changes 12 | of user code. 13 | 14 | .. contents:: 15 | :local: 16 | :depth: 1 17 | 18 | 19 | .. _identity_map_global: 20 | 21 | Using the identity map globally 22 | ------------------------------- 23 | 24 | The easiest way to use the identity map implementation is to set the 25 | ``PREFETCH_UTILS_DEFAULT_IMPLEMENTATION`` setting:: 26 | 27 | PREFETCH_UTILS_DEFAULT_IMPLEMENTATION = ( 28 | 'django_prefetch_utils.identity_map.prefetch_related_objects' 29 | ) 30 | 31 | This will make it so that all calls to 32 | :mod:`django.db.models.query.prefetch_related_objects` will use the 33 | identity map implementation. 34 | 35 | If at any point you which to use Django's default implementation, you can use 36 | the :func:`~django_prefetch_utils.selector.use_original_prefetch_related_objects` 37 | context decorator:: 38 | 39 | from from django_prefetch_utils.selector import use_original_prefetch_related_objects 40 | 41 | @use_original_prefetch_related_objects() 42 | def some_function(): 43 | return Dog.objects.prefetch_related("toys")[0] # uses default impl. 44 | 45 | 46 | Using the identity map locally 47 | ------------------------------ 48 | 49 | The 50 | :func:`~django_prefetch_utils.identity_map.use_prefetch_identity_map` 51 | context decorator can be used if you want to use identity map 52 | implementation without using it :ref:`globally 53 | `:: 54 | 55 | @use_prefetch_identity_map() 56 | def some_function(): 57 | return Dog.objects.prefetch_related('toys')[0] # uses identity map impl. 58 | 59 | 60 | Persisting the identity map across calls 61 | ---------------------------------------- 62 | 63 | There may be times where you want to use the same identity map across 64 | different calls to ``prefetch_related_objects``. In that case, you 65 | can use the 66 | :func:`~django_prefetch_utils.identity_map.persistent.use_persistent_prefetch_identity_map`:: 67 | 68 | def some_function(): 69 | with use_persistent_prefetch_identity_map() as identity_map: 70 | dogs = list(Dogs.objects.prefetch_related("toys")) 71 | 72 | with use_persistent_prefetch_identity_map(identity_map): 73 | # No queries are done here since all of the toys 74 | # have been fetched and stored in *identity_map* 75 | prefetch_related_objects(dogs, "favorite_toy") 76 | 77 | It can also be used as a decorator:: 78 | 79 | @use_persistent_prefetch_identity_map() 80 | def some_function(): 81 | dogs = list(Dogs.objects.prefetch_related("toys")) 82 | 83 | # The toy.dog instances will be identical (not just equal) 84 | # to the ones fetched on the line above 85 | toys = list(Toy.objects.prefetch_related("dog")) 86 | ... 87 | 88 | @use_persistent_prefetch_identity_map(pass_identity_map=True) 89 | def some_function(identity_map): 90 | dogs = list(Dogs.objects.prefetch_related("toys")) 91 | toys = list(Toy.objects.prefetch_related("dog")) 92 | ... 93 | 94 | Note that when 95 | :func:`~django_prefetch_utils.identity_map.persistent.use_persistent_prefetch_identity_map` 96 | is active, then ``QuerySet._fetch_all`` will be monkey-patched so that any 97 | objects fetched will be added to / checked against the identity map. 98 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. toctree:: 2 | :maxdepth: 2 3 | :hidden: 4 | 5 | self 6 | getting_started 7 | usage 8 | reference/index 9 | contributing 10 | authors 11 | changelog 12 | 13 | .. include:: ../README.rst 14 | 15 | Indices and tables 16 | ================== 17 | 18 | * :ref:`genindex` 19 | * :ref:`modindex` 20 | * :ref:`search` 21 | -------------------------------------------------------------------------------- /docs/reference/descriptors.rst: -------------------------------------------------------------------------------- 1 | django_prefetch_utils.descriptors 2 | ================================== 3 | 4 | .. automodule:: django_prefetch_utils.descriptors 5 | :members: 6 | 7 | Base 8 | ---- 9 | 10 | .. automodule:: django_prefetch_utils.descriptors.base 11 | :members: 12 | 13 | 14 | Via Lookup 15 | ---------- 16 | 17 | .. automodule:: django_prefetch_utils.descriptors.via_lookup 18 | :members: 19 | 20 | Annotation 21 | ---------- 22 | 23 | .. automodule:: django_prefetch_utils.descriptors.annotation 24 | :members: 25 | 26 | Top Child 27 | --------- 28 | 29 | .. automodule:: django_prefetch_utils.descriptors.top_child 30 | :members: 31 | 32 | Equal Fields 33 | ------------ 34 | 35 | .. automodule:: django_prefetch_utils.descriptors.equal_fields 36 | :members: 37 | -------------------------------------------------------------------------------- /docs/reference/identity_map.rst: -------------------------------------------------------------------------------- 1 | django_prefetch_utils.identity_map 2 | ================================== 3 | 4 | .. testsetup:: 5 | 6 | from django_prefetch_utils import * 7 | 8 | 9 | .. automodule:: django_prefetch_utils.identity_map 10 | :members: 11 | 12 | Persistent Identity Map 13 | ----------------------- 14 | 15 | .. automodule:: django_prefetch_utils.identity_map.persistent 16 | :members: 17 | 18 | 19 | Maps 20 | ---- 21 | 22 | .. automodule:: django_prefetch_utils.identity_map.maps 23 | :members: 24 | 25 | Wrappers 26 | -------- 27 | 28 | .. automodule:: django_prefetch_utils.identity_map.wrappers 29 | :members: 30 | -------------------------------------------------------------------------------- /docs/reference/index.rst: -------------------------------------------------------------------------------- 1 | Reference 2 | ========= 3 | 4 | .. toctree:: 5 | :glob: 6 | 7 | descriptors 8 | selector 9 | identity_map 10 | -------------------------------------------------------------------------------- /docs/reference/selector.rst: -------------------------------------------------------------------------------- 1 | django_prefetch_utils.selector 2 | ============================== 3 | 4 | .. testsetup:: 5 | 6 | from django_prefetch_utils import * 7 | 8 | 9 | .. automodule:: django_prefetch_utils.selector 10 | :members: 11 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx>=1.3 2 | sphinx-rtd-theme 3 | -e . 4 | -------------------------------------------------------------------------------- /docs/spelling_wordlist.txt: -------------------------------------------------------------------------------- 1 | builtin 2 | builtins 3 | classmethod 4 | staticmethod 5 | classmethods 6 | staticmethods 7 | args 8 | kwargs 9 | callstack 10 | Changelog 11 | Indices 12 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | .. toctree:: 2 | :maxdepth: 2 3 | :hidden: 4 | 5 | descriptors_usage 6 | identity_map 7 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 120 3 | target-version = ['py37'] 4 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | 4 | 5 | [flake8] 6 | max-line-length = 140 7 | exclude = */migrations/* 8 | 9 | [tool:pytest] 10 | testpaths = tests 11 | norecursedirs = 12 | .git 13 | .tox 14 | .env 15 | dist 16 | build 17 | migrations 18 | 19 | python_files = 20 | test_*.py 21 | *_test.py 22 | *_tests.py 23 | tests.py 24 | addopts = 25 | -ra 26 | --strict-markers 27 | --ignore=setup.py 28 | --ignore=ci 29 | --ignore=.eggs 30 | --tb=short 31 | --ds=settings 32 | 33 | [isort] 34 | force_single_line = True 35 | line_length = 120 36 | known_first_party = django_prefetch_utils,tests 37 | default_section = THIRDPARTY 38 | forced_separate = test_django_prefetch_utils 39 | skip = migrations 40 | 41 | [matrix] 42 | # This is the configuration for the `./bootstrap.py` script. 43 | # It generates `.travis.yml`, `tox.ini` and `appveyor.yml`. 44 | # 45 | # Syntax: [alias:] value [!variable[glob]] [&variable[glob]] 46 | # 47 | # alias: 48 | # - is used to generate the tox environment 49 | # - it's optional 50 | # - if not present the alias will be computed from the `value` 51 | # value: 52 | # - a value of "-" means empty 53 | # !variable[glob]: 54 | # - exclude the combination of the current `value` with 55 | # any value matching the `glob` in `variable` 56 | # - can use as many you want 57 | # &variable[glob]: 58 | # - only include the combination of the current `value` 59 | # when there's a value matching `glob` in `variable` 60 | # - can use as many you want 61 | 62 | python_versions = 63 | 3.7 64 | 3.8 65 | 3.9 66 | 3.10 67 | 68 | dependencies = 69 | 2.2: Django~=2.2.0 70 | 3.2: Django~=3.2.0 71 | 4.0: Django~=4.0.0 !python_versions[3.7] 72 | 73 | coverage_flags = 74 | cover: true 75 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- encoding: utf-8 -*- 3 | import io 4 | import re 5 | from glob import glob 6 | from os.path import basename 7 | from os.path import dirname 8 | from os.path import join 9 | from os.path import splitext 10 | 11 | from setuptools import find_packages 12 | from setuptools import setup 13 | 14 | 15 | def read(*names, **kwargs): 16 | with io.open(join(dirname(__file__), *names), encoding=kwargs.get("encoding", "utf8")) as fh: 17 | return fh.read() 18 | 19 | 20 | setup( 21 | name="django-prefetch-utils", 22 | version="0.2.0", 23 | license="BSD 3-Clause License", 24 | description="An library of utilities and enhancements for Django's prefetch_related system.", 25 | long_description="%s\n%s" 26 | % ( 27 | re.compile("^.. start-badges.*^.. end-badges", re.M | re.S).sub("", read("README.rst")), 28 | re.sub(":[a-z]+:`~?(.*?)`", r"``\1``", read("CHANGELOG.rst")), 29 | ), 30 | author="Mike Hansen", 31 | author_email="mike@rover.com", 32 | url="https://github.com/roverdotcom/django-prefetch-utils", 33 | packages=find_packages("src"), 34 | package_dir={"": "src"}, 35 | py_modules=[splitext(basename(path))[0] for path in glob("src/*.py")], 36 | include_package_data=True, 37 | zip_safe=False, 38 | classifiers=[ 39 | # complete classifier list: http://pypi.python.org/pypi?%3Aaction=list_classifiers 40 | "Development Status :: 5 - Production/Stable", 41 | "Intended Audience :: Developers", 42 | "License :: OSI Approved :: BSD License", 43 | "Operating System :: Unix", 44 | "Operating System :: POSIX", 45 | "Operating System :: Microsoft :: Windows", 46 | "Programming Language :: Python", 47 | "Programming Language :: Python :: 3", 48 | "Programming Language :: Python :: 3.7", 49 | "Programming Language :: Python :: 3.8", 50 | "Programming Language :: Python :: 3.9", 51 | "Programming Language :: Python :: 3.10", 52 | "Programming Language :: Python :: Implementation :: CPython", 53 | "Programming Language :: Python :: Implementation :: PyPy", 54 | # uncomment if you test on these interpreters: 55 | # 'Programming Language :: Python :: Implementation :: IronPython', 56 | # 'Programming Language :: Python :: Implementation :: Jython', 57 | # 'Programming Language :: Python :: Implementation :: Stackless', 58 | "Topic :: Utilities", 59 | ], 60 | keywords=[ 61 | # eg: 'keyword1', 'keyword2', 'keyword3', 62 | ], 63 | install_requires=["django>=2.2", "wrapt>=1.11"], 64 | extras_require={ 65 | # eg: 66 | # 'rst': ['docutils>=0.11'], 67 | # ':python_version=="2.6"': ['argparse'], 68 | }, 69 | ) 70 | -------------------------------------------------------------------------------- /src/django_prefetch_utils/__init__.py: -------------------------------------------------------------------------------- 1 | import django 2 | 3 | __version__ = "0.2.0" 4 | 5 | 6 | if django.VERSION < (3, 2): 7 | default_app_config = "django_prefetch_utils.apps.DjangoPrefetchUtilsAppConfig" 8 | -------------------------------------------------------------------------------- /src/django_prefetch_utils/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.conf import settings 3 | from django.utils.module_loading import import_string 4 | 5 | 6 | class DjangoPrefetchUtilsAppConfig(AppConfig): 7 | name = "django_prefetch_utils" 8 | 9 | def ready(self): 10 | from django_prefetch_utils.selector import enable_prefetch_related_objects_selector 11 | 12 | enable_prefetch_related_objects_selector() 13 | self.set_default_prefetch_related_objects_implementation() 14 | 15 | def set_default_prefetch_related_objects_implementation(self): 16 | from django_prefetch_utils.selector import set_default_prefetch_related_objects 17 | 18 | selected = getattr(settings, "PREFETCH_UTILS_DEFAULT_IMPLEMENTATION", None) 19 | if selected is None: 20 | return 21 | 22 | if isinstance(selected, str): 23 | selected = import_string(selected) 24 | 25 | set_default_prefetch_related_objects(selected) 26 | -------------------------------------------------------------------------------- /src/django_prefetch_utils/backport.py: -------------------------------------------------------------------------------- 1 | import copy 2 | 3 | from django.core import exceptions 4 | from django.db.models.constants import LOOKUP_SEP 5 | from django.db.models.query import normalize_prefetch_lookups 6 | from django.utils.functional import cached_property 7 | 8 | 9 | def prefetch_related_objects(model_instances, *related_lookups): 10 | """ 11 | Populate prefetched object caches for a list of model instances based on 12 | the lookups/Prefetch instances given. 13 | """ 14 | if not model_instances: 15 | return # nothing to do 16 | 17 | # We need to be able to dynamically add to the list of prefetch_related 18 | # lookups that we look up (see below). So we need some book keeping to 19 | # ensure we don't do duplicate work. 20 | done_queries = {} # dictionary of things like 'foo__bar': [results] 21 | 22 | auto_lookups = set() # we add to this as we go through. 23 | followed_descriptors = set() # recursion protection 24 | 25 | all_lookups = normalize_prefetch_lookups(reversed(related_lookups)) 26 | while all_lookups: 27 | lookup = all_lookups.pop() 28 | if lookup.prefetch_to in done_queries: 29 | if lookup.queryset is not None: 30 | raise ValueError( 31 | "'%s' lookup was already seen with a different queryset. " 32 | "You may need to adjust the ordering of your lookups." % lookup.prefetch_to 33 | ) 34 | 35 | continue 36 | 37 | # Top level, the list of objects to decorate is the result cache 38 | # from the primary QuerySet. It won't be for deeper levels. 39 | obj_list = model_instances 40 | 41 | through_attrs = lookup.prefetch_through.split(LOOKUP_SEP) 42 | for level, through_attr in enumerate(through_attrs): 43 | # Prepare main instances 44 | if not obj_list: 45 | break 46 | 47 | prefetch_to = lookup.get_current_prefetch_to(level) 48 | if prefetch_to in done_queries: 49 | # Skip any prefetching, and any object preparation 50 | obj_list = done_queries[prefetch_to] 51 | continue 52 | 53 | # Prepare objects: 54 | good_objects = True 55 | for obj in obj_list: 56 | # Since prefetching can re-use instances, it is possible to have 57 | # the same instance multiple times in obj_list, so obj might 58 | # already be prepared. 59 | if not hasattr(obj, "_prefetched_objects_cache"): 60 | try: 61 | obj._prefetched_objects_cache = {} 62 | except (AttributeError, TypeError): 63 | # Must be an immutable object from 64 | # values_list(flat=True), for example (TypeError) or 65 | # a QuerySet subclass that isn't returning Model 66 | # instances (AttributeError), either in Django or a 3rd 67 | # party. prefetch_related() doesn't make sense, so quit. 68 | good_objects = False 69 | break 70 | if not good_objects: 71 | break 72 | 73 | # Descend down tree 74 | 75 | # We assume that objects retrieved are homogeneous (which is the premise 76 | # of prefetch_related), so what applies to first object applies to all. 77 | first_obj = obj_list[0] 78 | to_attr = lookup.get_current_to_attr(level)[0] 79 | prefetcher, descriptor, attr_found, is_fetched = get_prefetcher(first_obj, through_attr, to_attr) 80 | 81 | if not attr_found: 82 | raise AttributeError( 83 | "Cannot find '%s' on %s object, '%s' is an invalid " 84 | "parameter to prefetch_related()" 85 | % (through_attr, first_obj.__class__.__name__, lookup.prefetch_through) 86 | ) 87 | 88 | if level == len(through_attrs) - 1 and prefetcher is None: 89 | # Last one, this *must* resolve to something that supports 90 | # prefetching, otherwise there is no point adding it and the 91 | # developer asking for it has made a mistake. 92 | raise ValueError( 93 | "'%s' does not resolve to an item that supports " 94 | "prefetching - this is an invalid parameter to " 95 | "prefetch_related()." % lookup.prefetch_through 96 | ) 97 | 98 | obj_to_fetch = None 99 | if prefetcher is not None: 100 | obj_to_fetch = [obj for obj in obj_list if not is_fetched(obj)] 101 | 102 | if obj_to_fetch: 103 | obj_list, additional_lookups = prefetch_one_level(obj_to_fetch, prefetcher, lookup, level) 104 | # We need to ensure we don't keep adding lookups from the 105 | # same relationships to stop infinite recursion. So, if we 106 | # are already on an automatically added lookup, don't add 107 | # the new lookups from relationships we've seen already. 108 | if not (prefetch_to in done_queries and lookup in auto_lookups and descriptor in followed_descriptors): 109 | done_queries[prefetch_to] = obj_list 110 | new_lookups = normalize_prefetch_lookups(reversed(additional_lookups), prefetch_to) 111 | auto_lookups.update(new_lookups) 112 | all_lookups.extend(new_lookups) 113 | followed_descriptors.add(descriptor) 114 | else: 115 | # Either a singly related object that has already been fetched 116 | # (e.g. via select_related), or hopefully some other property 117 | # that doesn't support prefetching but needs to be traversed. 118 | 119 | # We replace the current list of parent objects with the list 120 | # of related objects, filtering out empty or missing values so 121 | # that we can continue with nullable or reverse relations. 122 | new_obj_list = [] 123 | for obj in obj_list: 124 | if through_attr in getattr(obj, "_prefetched_objects_cache", ()): 125 | # If related objects have been prefetched, use the 126 | # cache rather than the object's through_attr. 127 | new_obj = list(obj._prefetched_objects_cache.get(through_attr)) 128 | else: 129 | try: 130 | new_obj = getattr(obj, through_attr) 131 | except exceptions.ObjectDoesNotExist: 132 | continue 133 | if new_obj is None: 134 | continue 135 | # We special-case `list` rather than something more generic 136 | # like `Iterable` because we don't want to accidentally match 137 | # user models that define __iter__. 138 | if isinstance(new_obj, list): 139 | new_obj_list.extend(new_obj) 140 | else: 141 | new_obj_list.append(new_obj) 142 | obj_list = new_obj_list 143 | 144 | 145 | def get_prefetcher(instance, through_attr, to_attr): 146 | """ 147 | For the attribute 'through_attr' on the given instance, find 148 | an object that has a get_prefetch_queryset(). 149 | Return a 4 tuple containing: 150 | (the object with get_prefetch_queryset (or None), 151 | the descriptor object representing this relationship (or None), 152 | a boolean that is False if the attribute was not found at all, 153 | a function that takes an instance and returns a boolean that is True if 154 | the attribute has already been fetched for that instance) 155 | """ 156 | 157 | def has_to_attr_attribute(instance): 158 | return hasattr(instance, to_attr) 159 | 160 | prefetcher = None 161 | is_fetched = has_to_attr_attribute 162 | 163 | # For singly related objects, we have to avoid getting the attribute 164 | # from the object, as this will trigger the query. So we first try 165 | # on the class, in order to get the descriptor object. 166 | rel_obj_descriptor = getattr(instance.__class__, through_attr, None) 167 | if rel_obj_descriptor is None: 168 | attr_found = hasattr(instance, through_attr) 169 | else: 170 | attr_found = True 171 | if rel_obj_descriptor: 172 | # singly related object, descriptor object has the 173 | # get_prefetch_queryset() method. 174 | if hasattr(rel_obj_descriptor, "get_prefetch_queryset"): 175 | prefetcher = rel_obj_descriptor 176 | is_fetched = rel_obj_descriptor.is_cached 177 | else: 178 | # descriptor doesn't support prefetching, so we go ahead and get 179 | # the attribute on the instance rather than the class to 180 | # support many related managers 181 | rel_obj = getattr(instance, through_attr) 182 | if hasattr(rel_obj, "get_prefetch_queryset"): 183 | prefetcher = rel_obj 184 | if through_attr != to_attr: 185 | # Special case cached_property instances because hasattr 186 | # triggers attribute computation and assignment. 187 | if isinstance(getattr(instance.__class__, to_attr, None), cached_property): 188 | 189 | def has_cached_property(instance): 190 | return to_attr in instance.__dict__ 191 | 192 | is_fetched = has_cached_property 193 | else: 194 | 195 | def in_prefetched_cache(instance): 196 | return through_attr in instance._prefetched_objects_cache 197 | 198 | is_fetched = in_prefetched_cache 199 | return prefetcher, rel_obj_descriptor, attr_found, is_fetched 200 | 201 | 202 | def prefetch_one_level(instances, prefetcher, lookup, level): 203 | """ 204 | Helper function for prefetch_related_objects(). 205 | 206 | Run prefetches on all instances using the prefetcher object, 207 | assigning results to relevant caches in instance. 208 | 209 | Return the prefetched objects along with any additional prefetches that 210 | must be done due to prefetch_related lookups found from default managers. 211 | """ 212 | # prefetcher must have a method get_prefetch_queryset() which takes a list 213 | # of instances, and returns a tuple: 214 | 215 | # (queryset of instances of self.model that are related to passed in instances, 216 | # callable that gets value to be matched for returned instances, 217 | # callable that gets value to be matched for passed in instances, 218 | # boolean that is True for singly related objects, 219 | # cache or field name to assign to, 220 | # boolean that is True when the previous argument is a cache name vs a field name). 221 | 222 | # The 'values to be matched' must be hashable as they will be used 223 | # in a dictionary. 224 | 225 | (rel_qs, rel_obj_attr, instance_attr, single, cache_name, is_descriptor) = prefetcher.get_prefetch_queryset( 226 | instances, lookup.get_current_queryset(level) 227 | ) 228 | # We have to handle the possibility that the QuerySet we just got back 229 | # contains some prefetch_related lookups. We don't want to trigger the 230 | # prefetch_related functionality by evaluating the query. Rather, we need 231 | # to merge in the prefetch_related lookups. 232 | # Copy the lookups in case it is a Prefetch object which could be reused 233 | # later (happens in nested prefetch_related). 234 | additional_lookups = [ 235 | copy.copy(additional_lookup) for additional_lookup in getattr(rel_qs, "_prefetch_related_lookups", ()) 236 | ] 237 | if additional_lookups: 238 | # Don't need to clone because the manager should have given us a fresh 239 | # instance, so we access an internal instead of using public interface 240 | # for performance reasons. 241 | rel_qs._prefetch_related_lookups = () 242 | 243 | all_related_objects = list(rel_qs) 244 | 245 | rel_obj_cache = {} 246 | for rel_obj in all_related_objects: 247 | rel_attr_val = rel_obj_attr(rel_obj) 248 | rel_obj_cache.setdefault(rel_attr_val, []).append(rel_obj) 249 | 250 | to_attr, as_attr = lookup.get_current_to_attr(level) 251 | # Make sure `to_attr` does not conflict with a field. 252 | if as_attr and instances: 253 | # We assume that objects retrieved are homogeneous (which is the premise 254 | # of prefetch_related), so what applies to first object applies to all. 255 | model = instances[0].__class__ 256 | try: 257 | model._meta.get_field(to_attr) 258 | except exceptions.FieldDoesNotExist: 259 | pass 260 | else: 261 | msg = "to_attr={} conflicts with a field on the {} model." 262 | raise ValueError(msg.format(to_attr, model.__name__)) 263 | 264 | # Whether or not we're prefetching the last part of the lookup. 265 | leaf = len(lookup.prefetch_through.split(LOOKUP_SEP)) - 1 == level 266 | 267 | for obj in instances: 268 | instance_attr_val = instance_attr(obj) 269 | vals = rel_obj_cache.get(instance_attr_val, []) 270 | 271 | if single: 272 | val = vals[0] if vals else None 273 | if as_attr: 274 | # A to_attr has been given for the prefetch. 275 | setattr(obj, to_attr, val) 276 | elif is_descriptor: 277 | # cache_name points to a field name in obj. 278 | # This field is a descriptor for a related object. 279 | setattr(obj, cache_name, val) 280 | else: 281 | # No to_attr has been given for this prefetch operation and the 282 | # cache_name does not point to a descriptor. Store the value of 283 | # the field in the object's field cache. 284 | obj._state.fields_cache[cache_name] = val 285 | else: 286 | if as_attr: 287 | setattr(obj, to_attr, vals) 288 | else: 289 | manager = getattr(obj, to_attr) 290 | if leaf and lookup.queryset is not None: 291 | qs = manager._apply_rel_filters(lookup.queryset) 292 | else: 293 | qs = manager.get_queryset() 294 | qs._result_cache = vals 295 | # We don't want the individual qs doing prefetch_related now, 296 | # since we have merged this into the current work. 297 | qs._prefetch_done = True 298 | obj._prefetched_objects_cache[cache_name] = qs 299 | return all_related_objects, additional_lookups 300 | -------------------------------------------------------------------------------- /src/django_prefetch_utils/descriptors/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module is provides a number of classes to help writing 3 | descriptors which play nicely with Django's ``prefetch_related`` 4 | system. A 5 | `general guide to descriptors `_ 6 | can be found in the Python documentation. 7 | """ 8 | 9 | from .annotation import AnnotationDescriptor # noqa 10 | from .base import GenericPrefetchRelatedDescriptor # noqa 11 | from .base import GenericSinglePrefetchRelatedDescriptorMixin # noqa 12 | from .equal_fields import EqualFieldsDescriptor # noqa 13 | from .top_child import TopChildDescriptorFromField # noqa 14 | from .top_child import TopChildDescriptorFromGenericRelation # noqa 15 | from .via_lookup import RelatedQuerySetDescriptorViaLookup # noqa 16 | from .via_lookup import RelatedQuerySetDescriptorViaLookupBase # noqa 17 | from .via_lookup import RelatedSingleObjectDescriptorViaLookup # noqa 18 | -------------------------------------------------------------------------------- /src/django_prefetch_utils/descriptors/annotation.py: -------------------------------------------------------------------------------- 1 | from django.utils.functional import cached_property 2 | 3 | from .base import GenericPrefetchRelatedDescriptor 4 | from .base import GenericSinglePrefetchRelatedDescriptorMixin 5 | 6 | 7 | class AnnotationDescriptor(GenericSinglePrefetchRelatedDescriptorMixin, GenericPrefetchRelatedDescriptor): 8 | """ 9 | This descriptor behaves like an annotated value would appear 10 | on a model. It lets you turn an annotation into a prefetch at 11 | the cost of an additional query:: 12 | 13 | >>> class Author(models.Model): 14 | ... book_count = AnnotationDescriptor(Count('books')) 15 | ... 16 | authors.models.Author 17 | >>> author = Author.objects.get(name="Jane") 18 | >>> author.book_count 19 | 11 20 | >>> author = Author.objects.prefetch_related('book_count').get(name="Jane") 21 | >>> author.book_count # no queries done 22 | 11 23 | 24 | It works by storing a ``values_list`` tuple containing the annotated value 25 | on :attr:`cache_name` on the object. 26 | """ 27 | 28 | def __init__(self, annotation): 29 | self.annotation = annotation 30 | 31 | def get_prefetch_model_class(self): 32 | """ 33 | Returns the model class of the objects that are prefetched 34 | by this descriptor. 35 | 36 | :returns: subclass of :class:`django.db.models.model` 37 | """ 38 | return self.model 39 | 40 | @cached_property 41 | def cache_name(self): 42 | """ 43 | Returns the name of the attribute where we will cache the annotated 44 | value. We are overriding ``cache_name`` from 45 | :class:`GenericPrefetchRelatedDescriptor` so that we can just return 46 | the annotated value from :attr:`__get__`. 47 | 48 | :rtype: str 49 | """ 50 | return "_prefetched_{}".format(self.name) 51 | 52 | def __get__(self, obj, type=None): 53 | if obj is None: 54 | return self 55 | 56 | # Perform the query if we haven't already fetched the annotated value 57 | if not self.is_cached(obj): 58 | annotation_value = super().__get__(obj, type) 59 | setattr(obj, self.cache_name, annotation_value) 60 | 61 | return getattr(obj, self.cache_name)[1] 62 | 63 | def filter_queryset_for_instances(self, queryset, instances): 64 | """ 65 | Returns *queryset* filtered to the objects which are related to 66 | *instances*. 67 | 68 | :param list instances: instances of the class on which this 69 | descriptor is found 70 | :param QuerySet queryset: the queryset to filter for *instances* 71 | :rtype: :class:`django.db.models.QuerySet` 72 | """ 73 | queryset = ( 74 | queryset.filter(pk__in=[obj.pk for obj in instances]) 75 | .annotate(**{self.name: self.annotation}) 76 | .values_list("pk", self.name) 77 | ) 78 | return queryset 79 | 80 | def get_join_value_for_instance(self, instance): 81 | return instance.pk 82 | 83 | def get_join_value_for_related_obj(self, annotation_value): 84 | return annotation_value[0] 85 | -------------------------------------------------------------------------------- /src/django_prefetch_utils/descriptors/base.py: -------------------------------------------------------------------------------- 1 | import abc 2 | 3 | from django.db.models import Manager 4 | 5 | 6 | class GenericPrefetchRelatedDescriptorManager(Manager): 7 | """ 8 | A :class:`django.db.models.Manager` to be used in conjunction 9 | with :class:`RelatedQuerySetDescriptor`. 10 | """ 11 | 12 | def __init__(self, descriptor, instance): 13 | self.descriptor = descriptor 14 | self.instance = instance 15 | 16 | @property 17 | def cache_name(self): 18 | """ 19 | Returns the name used to store the prefetched related objects. 20 | 21 | :rtype: str 22 | """ 23 | return self.descriptor.cache_name 24 | 25 | def _apply_rel_filters(self, queryset): 26 | """ 27 | Returns *queryset* filtered to all of the objects which are 28 | related to :attr:`instance`. 29 | 30 | This internal method is used by Django's prefetch system. 31 | 32 | :rtype: :class:`django.db.models.QuerySet` 33 | """ 34 | return self.descriptor.filter_queryset_for_instances(queryset, [self.instance]) 35 | 36 | def get_queryset(self): 37 | """ 38 | Returns a queryset of objects related to :attr:`instance`. This 39 | method checks to see if the queryset has been cached in 40 | :attr:`instance._prefetched_objects_cache`. 41 | 42 | :rtype: :class:`django.db.models.QuerySet` 43 | """ 44 | try: 45 | return self.instance._prefetched_objects_cache[self.cache_name] 46 | except (AttributeError, KeyError): 47 | return self._apply_rel_filters(self.descriptor.get_queryset()) 48 | 49 | def get_prefetch_queryset(self, instances, queryset=None): 50 | """ 51 | This is the primary method used by Django's prefetch system to 52 | get all of the objects related to *instances*. 53 | 54 | :param list instances: a list of instances of the class where this 55 | descriptor appears 56 | :param queryset: an optional queryset 57 | :returns: the 5-tuple needed by Django's prefetch system. 58 | """ 59 | queryset = self.descriptor.get_queryset(queryset=queryset) 60 | qs = self.descriptor.filter_queryset_for_instances(queryset, instances) 61 | qs = self.descriptor.update_queryset_for_prefetching(qs) 62 | qs._add_hints(instance=instances[0]) 63 | return ( 64 | qs, 65 | self.descriptor.get_join_value_for_related_obj, 66 | self.descriptor.get_join_value_for_instance, 67 | self.descriptor.is_single, 68 | self.cache_name, 69 | True, # is_descriptor 70 | ) 71 | 72 | 73 | class GenericPrefetchRelatedDescriptor(abc.ABC): 74 | manager_class = GenericPrefetchRelatedDescriptorManager 75 | 76 | is_single = False 77 | 78 | # The following two instances attributes are defined by 79 | # the contribute_to_class method. 80 | name = None 81 | model = None 82 | 83 | @abc.abstractmethod 84 | def get_prefetch_model_class(self): 85 | """ 86 | Returns the model class of the objects that are prefetched 87 | by this descriptor. 88 | 89 | :returns: subclass of :class:`django.db.models.Model` 90 | """ 91 | 92 | @abc.abstractmethod 93 | def filter_queryset_for_instances(self, queryset, instances): 94 | """ 95 | Given a *queryset* for the related objects, returns that 96 | queryset filtered down to the ones related to *instance*. 97 | 98 | :returns: a queryset 99 | """ 100 | 101 | @abc.abstractmethod 102 | def get_join_value_for_instance(self, instance): 103 | """ 104 | Returns the value used to associate *instance* with its related 105 | objects. 106 | 107 | :param instance: an instance of :attr:`model` 108 | """ 109 | 110 | @abc.abstractmethod 111 | def get_join_value_for_related_obj(self, rel_obj): 112 | """ 113 | Returns the value used to associate *rel_obj* with its related 114 | instance. 115 | 116 | :param rel_obj: a related object 117 | """ 118 | 119 | def get_queryset(self, queryset=None): 120 | """ 121 | Returns the default queryset to use for the related objects. 122 | 123 | The purpose of taking the optional *queryset* parameter is so that 124 | a custom queryset can be passed in as part of the prefetching process, 125 | and any subclasses can apply their own filters to that. 126 | 127 | :param QuerySet queryset: an optional queryset to use instead of the 128 | default queryset for the model 129 | :rtype: :class:`django.db.models.QuerySet` 130 | """ 131 | if queryset is not None: 132 | return queryset 133 | 134 | model = self.get_prefetch_model_class() 135 | return model._default_manager.all() 136 | 137 | def contribute_to_class(self, cls, name): 138 | """ 139 | Sets the name of the descriptor and sets itself as an attribute on the 140 | class with the same name. 141 | 142 | This method is called by Django's 143 | :class:`django.db.models.base.Modelbase` with the class the descriptor 144 | is defined on as well as the name it is being set up. 145 | 146 | :returns: ``None`` 147 | """ 148 | setattr(cls, name, self) 149 | self.model = cls 150 | self.name = name 151 | 152 | @property 153 | def cache_name(self): 154 | """ 155 | Returns the dictionary key where the associated queryset will 156 | be stored on :attr:`instance._prefetched_objects_cache` after 157 | prefetching. 158 | 159 | :rtype: str 160 | """ 161 | return self.name 162 | 163 | def update_queryset_for_prefetching(self, queryset): 164 | """ 165 | Returns *queryset* updated with any additional changes needed 166 | when it is used as a queryset within ``get_prefetch_queryset``. 167 | 168 | :param QuerySet queryset: the queryset which will be returned 169 | as part of the ``get_prefetch_queryset`` method. 170 | :rtype: :class:`django.db.models.QuerySet` 171 | """ 172 | return queryset 173 | 174 | def __get__(self, obj, type=None): 175 | """ 176 | Returns itself if accessed from a class; otherwise it returns 177 | a :class:`RelatedQuerySetDescriptorManager` when accessed from 178 | an instance. 179 | 180 | :returns: *self* or an instance of :attr:`manager_class` 181 | """ 182 | if obj is None: 183 | return self 184 | return self.manager_class(self, obj) 185 | 186 | 187 | class GenericSinglePrefetchRelatedDescriptorMixin(object): 188 | is_single = True 189 | 190 | def __get__(self, obj, type=None): 191 | """ 192 | Returns itself if accessed from a class; otherwise it returns 193 | a :class:`RelatedQuerySetDescriptorManager` when accessed from 194 | an instance. 195 | 196 | :returns: *self* or an instance of :attr:`manager_class` 197 | """ 198 | if obj is None: 199 | return self 200 | manager = self.manager_class(self, obj) 201 | try: 202 | related_object = manager.get_queryset()[0] 203 | except IndexError: 204 | return None 205 | 206 | setattr(obj, self.cache_name, related_object) 207 | return related_object 208 | 209 | def get_queryset(self, queryset=None): 210 | """ 211 | Returns the default queryset to use for the related objects. 212 | 213 | The purpose of taking the optional *queryset* parameter is so that 214 | a custom queryset can be passed in as part of the prefetching process, 215 | and any subclasses can apply their own filters to that. 216 | 217 | :param QuerySet queryset: an optional queryset to use instead of the 218 | default queryset for the model 219 | :rtype: :class:`django.db.models.QuerySet` 220 | """ 221 | qs = super().get_queryset(queryset=queryset) 222 | # Remove warning from Django 3.1: RemovedInDjango31Warning: 223 | # QuerySet won't use Meta.ordering in Django 3.1. Add .order_by('id') to 224 | # retain the current query. 225 | return qs.order_by("pk") 226 | 227 | def is_cached(self, obj): 228 | """ 229 | Returns whether or not we've already fetched the related model 230 | for *obj*. 231 | 232 | :rtype: bool 233 | """ 234 | return self.cache_name in obj.__dict__ 235 | 236 | def get_prefetch_queryset(self, instances, queryset=None): 237 | """ 238 | This is the primary method used by Django's prefetch system to 239 | get all of the objects related to *instances*. 240 | 241 | :param list instances: a list of instances of the class where this 242 | descriptor appears 243 | :param queryset: an optional queryset 244 | :returns: the 5-tuple needed by Django's prefetch system. 245 | """ 246 | # We piggy-back on the implementation of 247 | # RelatedQuerySetDescriptorManager.get_prefetch_queryset 248 | manager = self.manager_class(self, None) 249 | return manager.get_prefetch_queryset(instances, queryset=queryset) 250 | -------------------------------------------------------------------------------- /src/django_prefetch_utils/descriptors/equal_fields.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | 3 | from django.apps import apps 4 | 5 | from .base import GenericPrefetchRelatedDescriptor 6 | 7 | 8 | class EqualFieldsDescriptor(GenericPrefetchRelatedDescriptor): 9 | """ 10 | A descriptor which provides a manager for objects which are related 11 | by having equal values for a series of columns:: 12 | 13 | >>> class Book(models.Model): 14 | ... title = models.CharField(max_length=32) 15 | ... published_year = models.IntegerField() 16 | >>> class Author(models.Model): 17 | ... birth_year = models.IntegerField() 18 | ... birth_books = EqualFieldsDescriptor(Book, [('birth_year', 'published_year')]) 19 | ... 20 | >>> # Get the books published in the year the author was born 21 | >>> author = Author.objects.prefetch_related('birth_books') 22 | >>> author.birth_books.count() # no queries are done here 23 | 10 24 | """ 25 | 26 | # An internal class to store the mapping between the fields on the two 27 | # models 28 | _FieldMapping = namedtuple("FieldMapping", ("self_field", "related_field")) 29 | 30 | def __init__(self, related_model, join_fields): 31 | """ 32 | :param on: A list of tuples which defines the fields to join on. 33 | The first element of the tuple is the field on this model, the second is 34 | the field on the related model. 35 | """ 36 | if not join_fields: 37 | raise ValueError("Must supply fields to join on") 38 | 39 | self._related_model = related_model 40 | self.join_fields = tuple(self._FieldMapping(*jf) for jf in self.preprocess_join_fields(join_fields)) 41 | 42 | def preprocess_join_fields(self, join_fields): 43 | """ 44 | :returns: a list of :attr:`_FieldMapping` objects. 45 | """ 46 | if isinstance(join_fields, str): 47 | join_fields = [join_fields] 48 | return [join_field if isinstance(join_field, tuple) else (join_field,) * 2 for join_field in join_fields] 49 | 50 | def get_prefetch_model_class(self): 51 | """ 52 | Returns the model class of the objects that are prefetched 53 | by this descriptor. 54 | 55 | :returns: subclass of :class:`django.db.models.model` 56 | """ 57 | if isinstance(self._related_model, str): 58 | self._related_model = apps.get_model(self._related_model) 59 | return self._related_model 60 | 61 | def get_join_value_for_related_obj(self, rel_obj): 62 | """ 63 | Returns a tuple of the join values for *rel_obj*. 64 | 65 | :rtype: tuple 66 | """ 67 | return tuple(getattr(rel_obj, fields.related_field) for fields in self.join_fields) 68 | 69 | def get_join_value_for_instance(self, instance): 70 | """ 71 | Returns a tuple of the join values for *instance*. 72 | 73 | :rtype: tuple 74 | """ 75 | return tuple(getattr(instance, fields.self_field) for fields in self.join_fields) 76 | 77 | def filter_queryset_for_instances(self, queryset, instances): 78 | """ 79 | Returns a :class:`QuerySet` which returns the top children 80 | for each of the parents in *instances*. 81 | 82 | :param QuerySet queryset: a queryset for the objects related to 83 | *instances* 84 | :type instances: list 85 | :rtype: :class:`django.db.models.QuerySet` 86 | """ 87 | # Use a simpler query when there's just one value: 88 | if len(self.join_fields) == 1: 89 | self_field, related_field = self.join_fields[0] 90 | values = [getattr(instance, self_field) for instance in instances] 91 | return queryset.filter(**{"{}__in".format(related_field): values}) 92 | 93 | # In the case of multiple join fields, we construct a queryset for each 94 | # instance and then union them together. 95 | instance_querysets = [] 96 | qs = queryset.order_by() # unioned querysets don't support ordering 97 | for instance in instances: 98 | filter_kwargs = {} 99 | for fields in self.join_fields: 100 | filter_kwargs[fields.related_field] = getattr(instance, fields.self_field) 101 | instance_querysets.append(qs.filter(**filter_kwargs)) 102 | return qs.none().union(*instance_querysets) 103 | -------------------------------------------------------------------------------- /src/django_prefetch_utils/descriptors/top_child.py: -------------------------------------------------------------------------------- 1 | import abc 2 | 3 | from django.apps import apps 4 | from django.contrib.contenttypes.models import ContentType 5 | from django.db import models 6 | from django.db.models import F 7 | from django.utils.functional import cached_property 8 | 9 | from .base import GenericPrefetchRelatedDescriptor 10 | from .base import GenericSinglePrefetchRelatedDescriptorMixin 11 | 12 | 13 | class TopChildDescriptor(GenericSinglePrefetchRelatedDescriptorMixin, GenericPrefetchRelatedDescriptor): 14 | """ 15 | An abstract class for creating prefetchable descriptors which correspond 16 | to the top child in a group of children associated to a parent model. 17 | 18 | For example, consider a descriptor for the most recent message in a 19 | conversation. In this case, the children would be the messages, and 20 | the parent would be the conversation. The ordering used to determine 21 | the "top child" would be ``-added``. 22 | """ 23 | 24 | @abc.abstractmethod 25 | def get_child_model(self): 26 | """ 27 | returns the :class:`~django.db.models.model` class for the 28 | children. 29 | """ 30 | 31 | @abc.abstractmethod 32 | def get_parent_model(self): 33 | """ 34 | returns the :class:`~django.db.models.model` class for the 35 | parents. 36 | """ 37 | 38 | @abc.abstractmethod 39 | def get_child_order_by(self): 40 | """ 41 | returns a tuple which will be used to place an ordering on the 42 | children so that we can return the "top" one. 43 | 44 | :rtype: tuple 45 | """ 46 | 47 | @abc.abstractmethod 48 | def get_parent_relation(self): 49 | """ 50 | returns the string which specifies how to associate a parent 51 | model to a child. 52 | 53 | for example, if the parent were :class:`common.models.user` and the 54 | child were :class:`services.models.service`, then this should be 55 | ``'provider__user'``. 56 | 57 | :rtype: str 58 | """ 59 | 60 | def get_prefetch_model_class(self): 61 | """ 62 | Returns the model class of the objects that are prefetched 63 | by this descriptor. 64 | 65 | :returns: subclass of :class:`django.db.models.model` 66 | """ 67 | return self.get_child_model() 68 | 69 | def get_child_filter_args(self): 70 | """ 71 | returns a tuple of all of the argument filters which should be 72 | used to filter the possible children returned. 73 | 74 | :rtype: tuple 75 | """ 76 | return () 77 | 78 | def get_child_filter_kwargs(self, **kwargs): 79 | """ 80 | returns a dictionary of all of the keyword argument filters 81 | which should be used to filter the possible children returned. 82 | 83 | :param dict kwargs: any overrides for the default filter 84 | :rtype: dict 85 | """ 86 | return dict({self.get_parent_relation(): models.OuterRef("pk")}, **kwargs) 87 | 88 | def get_subquery(self): 89 | """ 90 | returns a :class:`queryset` for all of the child models which 91 | should be considered. 92 | 93 | :rtype: :class:`queryset` 94 | """ 95 | return ( 96 | self.get_child_model() 97 | .objects.filter(*self.get_child_filter_args(), **self.get_child_filter_kwargs()) 98 | .order_by(*self.get_child_order_by()) 99 | .values_list("pk", flat=True) 100 | ) 101 | 102 | def get_top_child_pks(self, parent_pks): 103 | """ 104 | Returns a queryset for the primary keys of the top children for 105 | the parent models whose primary keys are in *parent_pks*. 106 | 107 | :param list parent_pks: a list of primary keys for the parent 108 | models whose children we want to fetch. 109 | :rtype: :class:`QuerySet` 110 | """ 111 | return ( 112 | self.get_parent_model() 113 | .objects.annotate(top_child_pk=models.Subquery(self.get_subquery()[:1], output_field=models.IntegerField())) 114 | .filter(pk__in=parent_pks) 115 | .values_list("top_child_pk", flat=True) 116 | ) 117 | 118 | @cached_property 119 | def parent_pk_annotation(self): 120 | """ 121 | Returns the name of the attribute which will be annotated 122 | on child instances and will correspond to the primary key 123 | of the associated parent. 124 | 125 | :rtype: str 126 | """ 127 | return "_{}_".format(type(self).__name__.lower()) 128 | 129 | def filter_queryset_for_instances(self, queryset, instances): 130 | """ 131 | Returns a :class:`QuerySet` which returns the top children 132 | for each of the parents in *instances*. 133 | 134 | .. note:: 135 | This does not filter the set of child models which are 136 | included in the "consideration set". To do that, please 137 | override :meth:`get_child_filter_args` and 138 | :meth:`get_child_filter_kwargs`. 139 | 140 | :param QuerySet queryset: the queryset of child objects to filter for 141 | *instances* 142 | :param list instances: a list of the parent models whose 143 | children we want to fetch. 144 | :rtype: :class:`django.db.models.QuerySet` 145 | """ 146 | parent_pks = [obj.pk for obj in instances] 147 | return queryset.filter(pk__in=list(self.get_top_child_pks(parent_pks))).annotate( 148 | **{self.parent_pk_annotation: F(self.get_parent_relation())} 149 | ) 150 | 151 | def get_join_value_for_related_obj(self, child): 152 | """ 153 | Returns the value used to associate the *child* with the 154 | parent object. In this case, it is the primary key of the parent. 155 | 156 | :rtype: int 157 | """ 158 | return getattr(child, self.parent_pk_annotation) 159 | 160 | def get_join_value_for_instance(self, parent): 161 | """ 162 | Returns the value used to associate the *parent* with the 163 | child fetched during the prefetching process. 164 | In this case, it is the primary key of the parent. 165 | 166 | :rtype: int 167 | """ 168 | return parent.pk 169 | 170 | 171 | class TopChildDescriptorFromFieldBase(TopChildDescriptor): 172 | """ 173 | A subclass of :class:`TopChildDescriptor` for use when the 174 | children are related to the parent by a foreign key. In that 175 | case, anyone implementing a subclass of this only needs to 176 | implement :meth:`get_child_field`. 177 | """ 178 | 179 | @abc.abstractmethod 180 | def get_child_field(self): 181 | """ 182 | Returns the field on the child model which is a foreign key 183 | to the parent model. 184 | 185 | :rtype: :class:`django.db.models.fields.Field` 186 | """ 187 | 188 | @cached_property 189 | def child_field(self): 190 | return self.get_child_field() 191 | 192 | def get_child_model(self): 193 | return self.child_field.model 194 | 195 | def get_parent_model(self): 196 | return self.child_field.related_model 197 | 198 | def get_parent_relation(self): 199 | return self.child_field.name 200 | 201 | 202 | class TopChildDescriptorFromField(TopChildDescriptorFromFieldBase): 203 | def __init__(self, field, order_by): 204 | self._field = field 205 | self._order_by = order_by 206 | super().__init__() 207 | 208 | def get_child_field(self): 209 | if isinstance(self._field, str): 210 | model_string, field_name = self._field.rsplit(".", 1) 211 | model = apps.get_model(model_string) 212 | self._field = model._meta.get_field(field_name) 213 | return self._field 214 | 215 | def get_child_order_by(self): 216 | return self._order_by 217 | 218 | 219 | class TopChildDescriptorFromGenericRelationBase(TopChildDescriptor): 220 | """ 221 | A subclass of :class:`TopChildDescriptor` for use when the children 222 | are described by a 223 | :class:`django.contrib.contenttypes.fields.GenericRelation`. 224 | """ 225 | 226 | @abc.abstractmethod 227 | def get_child_field(self): 228 | """ 229 | Returns the generic relation on the parent model for the children. 230 | 231 | :rtype: :class:`django.contrib.contenttypes.fields.GenericRelation` 232 | """ 233 | 234 | @cached_property 235 | def child_field(self): 236 | return self.get_child_field() 237 | 238 | @cached_property 239 | def content_type(self): 240 | """ 241 | Returns the content type of the parent model. 242 | """ 243 | return ContentType.objects.get_for_model(self.get_parent_model()) 244 | 245 | def get_child_model(self): 246 | """ 247 | Returns the :class:`~django.db.models.Model` class for the 248 | children. 249 | """ 250 | return self.child_field.remote_field.model 251 | 252 | def get_parent_model(self): 253 | """ 254 | Returns the :class:`~django.db.models.Model` class for the parent. 255 | """ 256 | return self.child_field.model 257 | 258 | def get_parent_relation(self): 259 | """ 260 | Returns the name of the field on the child corresponding to the 261 | object primary key. 262 | 263 | :rtype: str 264 | """ 265 | return self.child_field.object_id_field_name 266 | 267 | def apply_content_type_filter(self, queryset): 268 | """ 269 | Filters the (child) *queryset* to only be those that correspond 270 | to :attr:`content_type`. 271 | 272 | :rtype: :class:`django.db.models.QuerySet` 273 | """ 274 | return queryset.filter(**{self.child_field.content_type_field_name: self.content_type.id}) 275 | 276 | def get_queryset(self, queryset=None): 277 | """ 278 | Returns a :class:`QuerySet` which returns the top children 279 | for each of the parents who have primary keys in *parent_pks*. 280 | 281 | :rtype: :class:`django.db.models.QuerySet` 282 | """ 283 | queryset = super().get_queryset(queryset=queryset) 284 | return self.apply_content_type_filter(queryset) 285 | 286 | def get_subquery(self): 287 | """ 288 | Returns a :class:`QuerySet` for all of the child models which 289 | should be considered. 290 | 291 | :rtype: :class:`django.db.models.QuerySet` 292 | """ 293 | subquery = super().get_subquery() 294 | return self.apply_content_type_filter(subquery) 295 | 296 | 297 | class TopChildDescriptorFromGenericRelation(TopChildDescriptorFromGenericRelationBase): 298 | """ 299 | For further customization, 300 | """ 301 | 302 | def __init__(self, generic_relation, order_by): 303 | self._generic_relation = generic_relation 304 | self._order_by = order_by 305 | super().__init__() 306 | 307 | def get_child_field(self): 308 | return getattr(self.model, self._generic_relation.name).field 309 | 310 | def get_child_order_by(self): 311 | return self._order_by 312 | -------------------------------------------------------------------------------- /src/django_prefetch_utils/descriptors/via_lookup.py: -------------------------------------------------------------------------------- 1 | import abc 2 | 3 | from django.apps import apps 4 | from django.db.models import F 5 | from django.utils.functional import cached_property 6 | 7 | from .base import GenericPrefetchRelatedDescriptor 8 | from .base import GenericSinglePrefetchRelatedDescriptorMixin 9 | 10 | 11 | class RelatedQuerySetDescriptorViaLookupBase(GenericPrefetchRelatedDescriptor): 12 | """ 13 | This is a base class for descriptors which provide access to 14 | related objects where the relationship between the instances on 15 | which this descriptor is defined and the related objects can by 16 | specified by a Django "lookup" which specifies the path from the 17 | related object to the model on which the descriptor is defined. 18 | """ 19 | 20 | @abc.abstractproperty 21 | def lookup(self): 22 | """ 23 | Returns the Django lookup string which describes the relationship 24 | from the related object to the one on which this descriptor 25 | is defined. 26 | 27 | :rtype: str 28 | """ 29 | 30 | @cached_property 31 | def obj_pk_annotation(self): 32 | """ 33 | Returns the name of an annotation to be used on the queryset so 34 | that we can easily get the primary key for the original object 35 | without having to instantiate any intermediary objects. 36 | 37 | :rtype: str 38 | """ 39 | return "_{}_".format(type(self).__name__.lower()) 40 | 41 | def filter_queryset_for_instances(self, queryset, instances): 42 | """ 43 | Returns *queryset* filtered to the objects which are related to 44 | *instances*. If *queryset* is ``None``, then :meth:`get_queryset` 45 | will be used instead. 46 | 47 | :param list instances: instances of the class on which this 48 | descriptor is found 49 | :param QuerySet queryset: the queryset to filter for *instances* 50 | :rtype: :class:`django.db.models.QuerySet` 51 | """ 52 | return queryset.filter(**{"{}__in".format(self.lookup): [obj.pk for obj in instances]}) 53 | 54 | def update_queryset_for_prefetching(self, queryset): 55 | """ 56 | Returns an updated *queryset* for use in ``get_prefetch_queryset``. 57 | 58 | We need to add an annotation to the queryset so that know which 59 | related model to associate with which original instance. 60 | 61 | :param QuerySet queryset: the queryset which will be returned 62 | as part of the ``get_prefetch_queryset`` method. 63 | :rtype: :class:`django.db.models.QuerySet` 64 | """ 65 | queryset = super().update_queryset_for_prefetching(queryset) 66 | return queryset.annotate(**{self.obj_pk_annotation: F(self.lookup)}) 67 | 68 | def get_join_value_for_instance(self, instance): 69 | """ 70 | Returns the value used to join the *instance* with the related 71 | object. In this case, it is the primary key of the instance. 72 | 73 | :rtype: int 74 | """ 75 | return instance.pk 76 | 77 | def get_join_value_for_related_obj(self, related_obj): 78 | """ 79 | Returns the value used to join the *related_obj* with the original 80 | instance. In this case, it is the primary key of the instance. 81 | 82 | :rtype: int 83 | """ 84 | return getattr(related_obj, self.obj_pk_annotation) 85 | 86 | 87 | class RelatedQuerySetDescriptorViaLookup(RelatedQuerySetDescriptorViaLookupBase): 88 | """ 89 | This provides a descriptor for access to related objects where the 90 | relationship between the instances on which this descriptor is 91 | defined and the related objects can be specified by a Django 92 | "lookup":: 93 | 94 | >>> class Author(models.Model): 95 | ... pass 96 | ... 97 | >>> class Book(models.Model): 98 | ... authors = models.ManyToManyField(Author, related_name='books') 99 | ... 100 | >>> class Reader(models.Model): 101 | ... books_read = models.ManyToManyField(Book, related_name='read_by') 102 | ... authors_read = RelatedQuerySetDescriptorViaLookupBase( 103 | ... Author, 'books__read_by' 104 | ... ) 105 | ... 106 | >>> reader = Reader.objects.prefetch_related('authors_read').first() 107 | >>> reader.authors_read.count() # no queries 108 | 42 109 | 110 | The lookup specifies the path from the related object to the model 111 | on which the descriptor is defined. 112 | """ 113 | 114 | def __init__(self, prefetch_model, lookup): 115 | self._prefetch_model = prefetch_model 116 | self._lookup = lookup 117 | 118 | @property 119 | def lookup(self): 120 | return self._lookup 121 | 122 | def get_prefetch_model_class(self): 123 | if isinstance(self._prefetch_model, str): 124 | self._prefetch_model = apps.get_model(self._prefetch_model) 125 | return self._prefetch_model 126 | 127 | 128 | class RelatedSingleObjectDescriptorViaLookup( 129 | GenericSinglePrefetchRelatedDescriptorMixin, RelatedQuerySetDescriptorViaLookup 130 | ): 131 | """ 132 | This provides a descriptor for access to a related object where the 133 | relationship to the instances on which this descriptor is 134 | defined and the related objects can be specified by a Django 135 | "lookup":: 136 | 137 | >>> class Author(models.Model): 138 | ... pass 139 | ... 140 | >>> class Book(models.Model): 141 | ... authors = models.ManyToManyField(Author, related_name='books') 142 | ... 143 | >>> class Reader(models.Model): 144 | ... books_read = models.ManyToManyField(Book, related_name='read_by') 145 | ... some_read_author = RelatedSingleObjectDescriptorViaLookup( 146 | ... Author, 'books__read_by' 147 | ... ) 148 | ... 149 | >>> reader = Reader.objects.prefetch_related('some_read_author').first() 150 | >>> reader.some_read_author # no queries 151 | 152 | 153 | The lookup specifies the path from the related object to the model 154 | on which the descriptor is defined. 155 | """ 156 | -------------------------------------------------------------------------------- /src/django_prefetch_utils/identity_map/__init__.py: -------------------------------------------------------------------------------- 1 | import copy 2 | 3 | from django.contrib.contenttypes.fields import GenericForeignKey 4 | from django.core import exceptions 5 | from django.db.models import Manager 6 | from django.db.models.constants import LOOKUP_SEP 7 | from django.db.models.fields.related_descriptors import ForwardManyToOneDescriptor 8 | from django.db.models.fields.related_descriptors import ForwardOneToOneDescriptor 9 | from django.db.models.fields.related_descriptors import ManyToManyDescriptor 10 | from django.db.models.fields.related_descriptors import ReverseManyToOneDescriptor 11 | from django.db.models.fields.related_descriptors import ReverseOneToOneDescriptor 12 | from django.db.models.query import normalize_prefetch_lookups 13 | from django.db.models.query import prefetch_one_level 14 | from django.utils.functional import cached_property 15 | 16 | from django_prefetch_utils.selector import override_prefetch_related_objects 17 | 18 | from .maps import PrefetchIdentityMap 19 | from .wrappers import ForwardDescriptorPrefetchWrapper 20 | from .wrappers import GenericForeignKeyPrefetchWrapper 21 | from .wrappers import IdentityMapPrefetcher 22 | from .wrappers import ManyToManyRelatedManagerWrapper 23 | from .wrappers import ReverseManyToOneDescriptorPrefetchWrapper 24 | from .wrappers import ReverseOneToOneDescriptorPrefetchWrapper 25 | 26 | 27 | def get_identity_map_prefetcher(identity_map, descriptor, prefetcher): 28 | if prefetcher is None: 29 | return None 30 | 31 | wrappers = { 32 | ForwardManyToOneDescriptor: ForwardDescriptorPrefetchWrapper, 33 | ForwardOneToOneDescriptor: ForwardDescriptorPrefetchWrapper, 34 | ReverseOneToOneDescriptor: ReverseOneToOneDescriptorPrefetchWrapper, 35 | ReverseManyToOneDescriptor: ReverseManyToOneDescriptorPrefetchWrapper, 36 | ManyToManyDescriptor: ManyToManyRelatedManagerWrapper, 37 | GenericForeignKey: GenericForeignKeyPrefetchWrapper, 38 | } 39 | wrapper_cls = wrappers.get(type(descriptor), IdentityMapPrefetcher) 40 | return wrapper_cls(identity_map, prefetcher) 41 | 42 | 43 | def get_prefetcher(obj_list, through_attr, to_attr): 44 | """ 45 | For the attribute *through_attr* on the given instance, finds 46 | an object that has a ``get_prefetch_queryset()``. 47 | 48 | Returns a 4 tuple containing: 49 | 50 | - (the object with get_prefetch_queryset (or None), 51 | - the descriptor object representing this relationship (or None), 52 | - a boolean that is False if the attribute was not found at all, 53 | - a list of the subset of *obj_list* that requires fetching 54 | """ 55 | instance = obj_list[0] 56 | prefetcher = None 57 | needs_fetching = obj_list 58 | 59 | # For singly related objects, we have to avoid getting the attribute 60 | # from the object, as this will trigger the query. So we first try 61 | # on the class, in order to get the descriptor object. 62 | rel_obj_descriptor = getattr(instance.__class__, through_attr, None) 63 | if rel_obj_descriptor is None: 64 | attr_found = hasattr(instance, through_attr) 65 | else: 66 | attr_found = True 67 | # singly related object, descriptor object has the 68 | # get_prefetch_queryset() method. 69 | if hasattr(rel_obj_descriptor, "get_prefetch_queryset"): 70 | prefetcher = rel_obj_descriptor 71 | needs_fetching = [obj for obj in obj_list if not rel_obj_descriptor.is_cached(obj)] 72 | else: 73 | # descriptor doesn't support prefetching, so we go ahead and get 74 | # the attribute on the instance rather than the class to 75 | # support many related managers 76 | rel_obj = getattr(instance, through_attr) 77 | if hasattr(rel_obj, "get_prefetch_queryset"): 78 | prefetcher = rel_obj 79 | if through_attr != to_attr: 80 | # Special case cached_property instances because hasattr 81 | # triggers attribute computation and assignment. 82 | if isinstance(getattr(instance.__class__, to_attr, None), cached_property): 83 | needs_fetching = [obj for obj in obj_list if to_attr not in obj.__dict__] 84 | else: 85 | needs_fetching = [obj for obj in obj_list if not hasattr(obj, to_attr)] 86 | else: 87 | needs_fetching = [obj for obj in obj_list if through_attr not in obj._prefetched_objects_cache] 88 | 89 | return prefetcher, rel_obj_descriptor, attr_found, needs_fetching 90 | 91 | 92 | def get_prefetched_objects_from_list(obj_list, through_attr): 93 | """ 94 | Returns all of the related objects in *obj_list* from *through_attr*. 95 | 96 | :type obj_list: list 97 | :type through_attr: str 98 | :rtype: list 99 | """ 100 | new_obj_list = [] 101 | for obj in obj_list: 102 | if through_attr in getattr(obj, "_prefetched_objects_cache", ()): 103 | # If related objects have been prefetched, use the 104 | # cache rather than the object's through_attr. 105 | new_obj = list(obj._prefetched_objects_cache.get(through_attr)) 106 | else: 107 | try: 108 | new_obj = getattr(obj, through_attr) 109 | except exceptions.ObjectDoesNotExist: 110 | continue 111 | if new_obj is None: 112 | continue 113 | # We special-case `list` rather than something more generic 114 | # like `Iterable` because we don't want to accidentally match 115 | # user models that define __iter__. 116 | if isinstance(new_obj, list): 117 | new_obj_list.extend(new_obj) 118 | elif isinstance(new_obj, Manager): 119 | # This case in needed for Django < 2.1 where the RelatedManager 120 | # returns the wrong cache name so that *through_attr* does not 121 | # appear in _prefetched_objects_cache. See Django #28723. 122 | new_obj_list.extend(new_obj.all()) 123 | else: 124 | new_obj_list.append(new_obj) 125 | return new_obj_list 126 | 127 | 128 | def get_default_prefetch_identity_map(): 129 | """ 130 | Returns an empty default identity map for use during prefetching. 131 | 132 | :rtype: :class:`django_prefetch_utils.identity_map.maps.PrefetchIdentityMap` 133 | """ 134 | return PrefetchIdentityMap() 135 | 136 | 137 | def prefetch_related_objects(*args, **kwargs): 138 | """ 139 | Calls :func:`prefetch_related_objects_impl` with a new identity map 140 | from :func:`get_default_prefetch_identity_map`:: 141 | 142 | >>> from django_prefetch_utils.identity_map import prefetch_related_objects 143 | >>> dogs = list(Dogs.objectss.all()) 144 | >>> prefetch_related_objects(dogs, 'toys') 145 | 146 | .. note:: 147 | 148 | This will create will not preserve the identity map across 149 | different calls to ``prefetched_related_objects``. For that, 150 | you need to use 151 | :func:`django_prefetch_utils.identity_map.persistent.use_persistent_prefetch_identity_map` 152 | 153 | """ 154 | return prefetch_related_objects_impl(get_default_prefetch_identity_map(), *args, **kwargs) 155 | 156 | 157 | def use_prefetch_identity_map(): 158 | """ 159 | A context decorator which enables the identity map version of 160 | ``prefetch_related_objects``:: 161 | 162 | with use_prefetch_identity_map(): 163 | dogs = list(Dogs.objects.prefetch_related('toys')) 164 | 165 | .. note:: 166 | 167 | A new identity map is created and used for each call of 168 | ``prefetched_related_objects``. 169 | """ 170 | return override_prefetch_related_objects(prefetch_related_objects) 171 | 172 | 173 | def prefetch_related_objects_impl(identity_map, model_instances, *related_lookups): 174 | """ 175 | An implementation of ``prefetch_related_objects`` which makes use 176 | of *identity_map* to keep track of all of the objects which have been 177 | fetched and reuses them where possible. 178 | """ 179 | if not model_instances: 180 | return # nothing to do 181 | 182 | # Create the identity map and add the model instances to it 183 | model_instances = [identity_map[instance] for instance in model_instances] 184 | 185 | # We need to be able to dynamically add to the list of prefetch_related 186 | # lookups that we look up (see below). So we need some book keeping to 187 | # ensure we don't do duplicate work. 188 | done_queries = {} # dictionary of things like 'foo__bar': [results] 189 | 190 | auto_lookups = set() # we add to this as we go through. 191 | 192 | all_lookups = normalize_prefetch_lookups(reversed(related_lookups)) 193 | 194 | def add_additional_lookups_from_queryset(prefix, queryset_or_lookups): 195 | if isinstance(queryset_or_lookups, (list, tuple)): 196 | additional_lookups = queryset_or_lookups 197 | else: 198 | additional_lookups = [ 199 | copy.copy(additional_lookup) 200 | for additional_lookup in getattr(queryset_or_lookups, "_prefetch_related_lookups", ()) 201 | ] 202 | 203 | if not additional_lookups: 204 | return 205 | 206 | new_lookups = normalize_prefetch_lookups(reversed(additional_lookups), prefix) 207 | all_lookups.extend(new_lookups) 208 | auto_lookups.update(new_lookups) 209 | 210 | while all_lookups: 211 | lookup = all_lookups.pop() 212 | 213 | if lookup.prefetch_to in done_queries: 214 | if lookup.queryset is not None: 215 | raise ValueError( 216 | "'%s' lookup was already seen with a different queryset. " 217 | "You may need to adjust the ordering of your lookups." % lookup.prefetch_to 218 | ) 219 | continue # pragma: no cover 220 | 221 | # Top level, the list of objects to decorate is the result cache 222 | # from the primary QuerySet. It won't be for deeper levels. 223 | obj_list = model_instances 224 | 225 | through_attrs = lookup.prefetch_through.split(LOOKUP_SEP) 226 | for level, through_attr in enumerate(through_attrs): 227 | # Prepare main instances 228 | if not obj_list: 229 | break 230 | 231 | prefetch_to = lookup.get_current_prefetch_to(level) 232 | if prefetch_to in done_queries: 233 | # Skip any prefetching, and any object preparation 234 | obj_list = done_queries[prefetch_to] 235 | continue 236 | 237 | # Prepare objects: 238 | good_objects = True 239 | for obj in obj_list: 240 | # Since prefetching can re-use instances, it is possible to have 241 | # the same instance multiple times in obj_list, so obj might 242 | # already be prepared. 243 | if not hasattr(obj, "_prefetched_objects_cache"): 244 | try: 245 | obj._prefetched_objects_cache = {} 246 | except (AttributeError, TypeError): 247 | # Must be an immutable object from 248 | # values_list(flat=True), for example (TypeError) or 249 | # a QuerySet subclass that isn't returning Model 250 | # instances (AttributeError), either in Django or a 3rd 251 | # party. prefetch_related() doesn't make sense, so quit. 252 | good_objects = False 253 | break 254 | if not good_objects: 255 | break 256 | 257 | # Descend down tree 258 | 259 | # We assume that objects retrieved are homogeneous (which is the premise 260 | # of prefetch_related), so what applies to first object applies to all. 261 | first_obj = obj_list[0] 262 | to_attr = lookup.get_current_to_attr(level)[0] 263 | prefetcher, descriptor, attr_found, needs_fetching = get_prefetcher(obj_list, through_attr, to_attr) 264 | prefetcher = get_identity_map_prefetcher(identity_map, descriptor, prefetcher) 265 | 266 | if not attr_found: 267 | raise AttributeError( 268 | "Cannot find '%s' on %s object, '%s' is an invalid " 269 | "parameter to prefetch_related()" 270 | % (through_attr, first_obj.__class__.__name__, lookup.prefetch_through) 271 | ) 272 | 273 | leaf = level == len(through_attrs) - 1 274 | if leaf and prefetcher is None: 275 | # Last one, this *must* resolve to something that supports 276 | # prefetching, otherwise there is no point adding it and the 277 | # developer asking for it has made a mistake. 278 | raise ValueError( 279 | "'%s' does not resolve to an item that supports " 280 | "prefetching - this is an invalid parameter to " 281 | "prefetch_related()." % lookup.prefetch_through 282 | ) 283 | 284 | if prefetcher is not None and needs_fetching: 285 | new_obj_list, additional_lookups = prefetch_one_level(needs_fetching, prefetcher, lookup, level) 286 | obj_list = get_prefetched_objects_from_list(obj_list, to_attr) 287 | done_queries[prefetch_to] = obj_list 288 | add_additional_lookups_from_queryset(prefetch_to, additional_lookups) 289 | else: 290 | # Either a singly related object that has already been fetched 291 | # (e.g. via select_related), or hopefully some other property 292 | # that doesn't support prefetching but needs to be traversed. 293 | 294 | # We replace the current list of parent objects with the list 295 | # of related objects, filtering out empty or missing values so 296 | # that we can continue with nullable or reverse relations. 297 | 298 | obj_list = get_prefetched_objects_from_list(obj_list, through_attr) 299 | 300 | if obj_list and leaf and lookup.queryset is not None: 301 | add_additional_lookups_from_queryset(prefetch_to, lookup.queryset) 302 | -------------------------------------------------------------------------------- /src/django_prefetch_utils/identity_map/maps.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | from weakref import WeakValueDictionary 3 | 4 | import wrapt 5 | 6 | 7 | class PrefetchIdentityMap(defaultdict): 8 | """ 9 | This class represents an identity map used to help ensure that 10 | equal Django model instances are identical during the prefetch 11 | process. 12 | 13 | >>> identity_map = PrefetchIdentityMap() 14 | >>> a = Author.objects.first() 15 | >>> b = Author.objects.first() 16 | >>> a is b 17 | False 18 | >>> identity_map[a] is a 19 | True 20 | >>> identity_map[b] is a 21 | True 22 | 23 | It is implemented as a defaultdictionary whose keys correspond 24 | the to types of Django models and whose values are a 25 | :class:`weakref.WeakValueDictionary` mapping primary keys to the 26 | associated Django model instance. 27 | """ 28 | 29 | def __init__(self): 30 | super().__init__(WeakValueDictionary) 31 | 32 | def __getitem__(self, obj): 33 | subdict = self.get_map_for_model(type(obj)) 34 | 35 | try: 36 | pk = obj.pk 37 | except AttributeError: 38 | return obj 39 | 40 | return subdict.setdefault(pk, obj) 41 | 42 | def get_map_for_model(self, model): 43 | """ 44 | Returns the the underlying dictionary 45 | 46 | :rtype: :class:`weakref.WeakValueDictionary` 47 | """ 48 | return super().__getitem__(model) 49 | 50 | 51 | class RelObjAttrMemoizingIdentityMap(wrapt.ObjectProxy): 52 | """ 53 | A wrapper for an identity map which provides a :meth:`rel_obj_attr` 54 | to be returned from a ``get_prefetch_queryset`` method. 55 | 56 | This is useful for cases when there is identifying information 57 | on the related object returned from the prefetcher which is not present 58 | on the equivalent object in the identity map. 59 | """ 60 | 61 | __slots__ = ("_self_rel_obj_attr", "_self_memo") 62 | 63 | def __init__(self, rel_obj_attr, wrapped): 64 | super().__init__(wrapped) 65 | self._self_rel_obj_attr = rel_obj_attr 66 | self._self_memo = {} 67 | 68 | def __getitem__(self, obj): 69 | new_obj = self.__wrapped__[obj] 70 | 71 | # Compute the rel_obj_attr on the original object and associate 72 | # it with the new object 73 | self._self_memo[new_obj] = self._self_rel_obj_attr(obj) 74 | 75 | return new_obj 76 | 77 | def rel_obj_attr(self, rel_obj): 78 | return self._self_memo[rel_obj] 79 | 80 | 81 | class AnnotatingIdentityMap(wrapt.ObjectProxy): 82 | __slots__ = ("_self_annotation_keys",) 83 | 84 | def __init__(self, annotation_keys, wrapped): 85 | super().__init__(wrapped) 86 | self._self_annotation_keys = annotation_keys 87 | 88 | def __getitem__(self, obj): 89 | new_obj = self.__wrapped__[obj] 90 | if new_obj is not obj: 91 | for key in self._self_annotation_keys: 92 | setattr(new_obj, key, getattr(obj, key)) 93 | return new_obj 94 | 95 | 96 | class SelectRelatedIdentityMap(wrapt.ObjectProxy): 97 | __slots__ = ("_self_select_related",) 98 | MISSING = object() 99 | 100 | def __init__(self, select_related, wrapped): 101 | super().__init__(wrapped) 102 | self._self_select_related = select_related 103 | 104 | def get_cached_value(self, field, instance): 105 | if not field.is_cached(instance): 106 | return self.MISSING 107 | return field.get_cached_value(instance) 108 | 109 | def set_cached_value(self, field, instance, value): 110 | field.set_cached_value(instance, value) 111 | 112 | def transfer_select_related(self, select_related, source, target): 113 | for key, sub_select_related in select_related.items(): 114 | field = source._meta.get_field(key) 115 | 116 | source_obj = self.get_cached_value(field, source) 117 | if source_obj is self.MISSING: 118 | source_obj = getattr(source, key) 119 | 120 | target_obj = self.__wrapped__[source_obj] 121 | self.set_cached_value(field, target, target_obj) 122 | self.transfer_select_related(sub_select_related, source=source_obj, target=target_obj) 123 | 124 | def __getitem__(self, obj): 125 | new_obj = self.__wrapped__[obj] 126 | self.transfer_select_related(self._self_select_related, source=obj, target=new_obj) 127 | return new_obj 128 | 129 | 130 | class ExtraIdentityMap(wrapt.ObjectProxy): 131 | """ 132 | This identity map wrapper 133 | """ 134 | 135 | __slots__ = ("_self_extra",) 136 | 137 | def __init__(self, extra, wrapped): 138 | super().__init__(wrapped) 139 | self._self_extra = extra 140 | 141 | def __getitem__(self, obj): 142 | new_obj = self.__wrapped__[obj] 143 | if new_obj is obj: 144 | return new_obj 145 | 146 | for key in self._self_extra: 147 | setattr(new_obj, key, getattr(obj, key)) 148 | 149 | return new_obj 150 | -------------------------------------------------------------------------------- /src/django_prefetch_utils/identity_map/persistent.py: -------------------------------------------------------------------------------- 1 | import threading 2 | from contextlib import ContextDecorator 3 | from functools import partial 4 | 5 | import wrapt 6 | from django.db.models.query import QuerySet 7 | 8 | from django_prefetch_utils.identity_map import get_default_prefetch_identity_map 9 | from django_prefetch_utils.identity_map import prefetch_related_objects_impl 10 | from django_prefetch_utils.selector import override_prefetch_related_objects 11 | 12 | from .wrappers import wrap_identity_map_for_queryset 13 | 14 | _active = threading.local() 15 | 16 | 17 | original_fetch_all = QuerySet._fetch_all 18 | 19 | 20 | class FetchAllDescriptor(object): 21 | """ 22 | This descriptor replaces ``QuerySet._fetch_all`` and applies 23 | an identity map to any objects fetched in a queryset. 24 | """ 25 | 26 | def __get__(self, queryset, type=None): 27 | if queryset is None: 28 | return self 29 | return partial(self._fetch_all, queryset) 30 | 31 | def _fetch_all(self, queryset): 32 | identity_map = getattr(_active, "value", None) 33 | if identity_map is None: 34 | return original_fetch_all(queryset) 35 | 36 | identity_map = wrap_identity_map_for_queryset(identity_map, queryset) 37 | if queryset._result_cache is None: 38 | queryset._result_cache = [identity_map[obj] for obj in queryset._iterable_class(queryset)] 39 | if queryset._prefetch_related_lookups and not queryset._prefetch_done: 40 | queryset._prefetch_related_objects() 41 | 42 | 43 | def enable_fetch_all_descriptor(): 44 | """ 45 | Replaces ``QuerySet._fetch_all`` with an instance of 46 | :class:`FetchAllDescriptor`. 47 | """ 48 | QuerySet._fetch_all = FetchAllDescriptor() 49 | 50 | 51 | def disable_fetch_all_descriptor(): 52 | """ 53 | Sets ``QuerySet._fetch_all`` to be the original method. 54 | """ 55 | QuerySet._fetch_all = original_fetch_all 56 | 57 | 58 | class use_persistent_prefetch_identity_map(ContextDecorator): 59 | """ 60 | A context decorator which allows the same identity map to be used 61 | across multiple calls to ``prefetch_related_objects``. 62 | 63 | :: 64 | 65 | with use_persistent_prefetch_identity_map(): 66 | dogs = list(Dogs.objects.prefetch_related("toys")) 67 | 68 | # The toy.dog instances will be identitical (not just equal) 69 | # to the ones fetched on the line above 70 | with self.assertNumQueries(1): 71 | toys = list(Toy.objects.prefetch_related("dog")) 72 | 73 | """ 74 | 75 | previous_active = None 76 | override_context_decorator = None 77 | 78 | def __init__(self, identity_map=None, pass_identity_map=False): 79 | self._identity_map = identity_map 80 | self.pass_identity_map = pass_identity_map 81 | 82 | def _recreate_cm(self): 83 | return self 84 | 85 | def __enter__(self): 86 | if self._identity_map is not None: 87 | identity_map = self._identity_map 88 | else: 89 | identity_map = get_default_prefetch_identity_map() 90 | enable_fetch_all_descriptor() 91 | self.previous_active = getattr(_active, "value", None) 92 | _active.value = identity_map 93 | self.override_context_decorator = override_prefetch_related_objects( 94 | partial(prefetch_related_objects_impl, identity_map) 95 | ) 96 | self.override_context_decorator.__enter__() 97 | return identity_map 98 | 99 | def __exit__(self, exc_type, exc_value, traceback): 100 | _active.value = self.previous_active 101 | self.previous_active = None 102 | self.override_context_decorator.__exit__(exc_type, exc_value, traceback) 103 | self.override_context_decorator = None 104 | 105 | def __call__(self, func): 106 | @wrapt.decorator 107 | def wrapper(wrapped, instance, args, kwargs): 108 | with self._recreate_cm() as identity_map: 109 | if self.pass_identity_map: 110 | args = (identity_map,) + args 111 | return wrapped(*args, **kwargs) 112 | 113 | return wrapper(func) 114 | -------------------------------------------------------------------------------- /src/django_prefetch_utils/selector.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module provides utilities for changing the implementation of 3 | ``prefetch_related_objects`` that Django uses. In order for these to 4 | work, :func:`enable_fetch_related_objects_selector` must be called. 5 | This will be done in ``AppConfig.ready`` if ``django_prefetch_utils`` 6 | is added to ``INSTALLED_APPS``. 7 | 8 | Once that has been called, then 9 | :func:`set_default_prefetch_related_objects` can be called to override 10 | the default implementation globally:: 11 | 12 | from django_prefetch_related.selector import set_default_prefetch_related_objects 13 | from django_prefetch_utils.identity_map import prefetch_related_objects 14 | 15 | set_default_prefetch_related_objects(prefetch_related_objects) 16 | 17 | This will be done as part of ``AppConfig.ready`` if the 18 | ``PREFETCH_UTILS_DEFAULT_IMPLEMENTATION`` setting is provided. 19 | 20 | To change the implementation used on a local basis, the 21 | :func:`override_prefetch_related_objects` or 22 | :func:`use_original_prefetch_related_objects` context decorators can 23 | be used:: 24 | 25 | from django_prefetch_utils.identity_map import prefetch_related_objects 26 | 27 | @use_original_prefetch_related_objects() 28 | def some_function(): 29 | dogs = list(Dog.objects.all()) # uses Django's implementation 30 | 31 | with override_prefetch_related_objects(prefetch_related_objects): 32 | toys = list(Toy.objects.all) # uses identity map implementation 33 | 34 | """ 35 | import threading 36 | from contextlib import ContextDecorator 37 | 38 | import django.db.models.query 39 | from django.db.models.query import prefetch_related_objects as original_prefetch_related_objects 40 | 41 | _active = threading.local() 42 | 43 | 44 | def enable_prefetch_related_objects_selector(): 45 | """ 46 | Changes ``django.db.models.query.prefetch_related_objects`` to an 47 | implemention which allows thread-local overrides. 48 | """ 49 | django.db.models.query.prefetch_related_objects = _prefetch_related_objects_selector 50 | 51 | 52 | def disable_prefetch_related_objects_selector(): 53 | """ 54 | Changes ``django.db.models.query.prefetch_related_objects`` to Django's 55 | original implementation of ``prefetch_related_objects``. 56 | """ 57 | django.db.models.query.prefetch_related_objects = original_prefetch_related_objects 58 | 59 | 60 | def _prefetch_related_objects_selector(*args, **kwargs): 61 | """ 62 | The implementation of ``prefetch_related_objects`` to be monkey-patched 63 | into ``django.db.models.query.prefetch_related_objects``. 64 | """ 65 | return get_prefetch_related_objects()(*args, **kwargs) 66 | 67 | 68 | def set_default_prefetch_related_objects(func): 69 | """ 70 | Sets the default implementation of ``prefetch_related_objects`` to be 71 | *func*:: 72 | 73 | >>> get_prefetch_related_objects() 74 | 75 | >>> set_default_prefetch_related_objects(some_implementation) 76 | >>> get_prefetch_related_objects() 77 | 78 | """ 79 | _active.value = func 80 | 81 | 82 | def remove_default_prefetch_related_objects(): 83 | """ 84 | Removes a custom default implementation of ``prefetch_related_objects``:: 85 | 86 | >>> set_default_prefetch_related_objects(some_implementation) 87 | >>> get_prefetch_related_objects() 88 | 89 | >>> remove_default_prefetch_related_objects() 90 | >>> get_prefetch_related_objects() 91 | 92 | """ 93 | _active.value = None 94 | 95 | 96 | def get_prefetch_related_objects(): 97 | """ 98 | Returns the active implementation of ``prefetch_related_objects``:: 99 | 100 | >>> from django_prefetch_utils.selector import get_prefetch_related_objects 101 | >>> get_prefetch_related_objects() 102 | 103 | 104 | :returns: a function 105 | """ 106 | active = getattr(_active, "value", None) 107 | return active or original_prefetch_related_objects 108 | 109 | 110 | class override_prefetch_related_objects(ContextDecorator): 111 | """ 112 | This context decorator allows one to chnage the implementation 113 | of ``prefetch_related_objects`` to be *func*. 114 | 115 | When the context manager or decorator exits, the implementation 116 | will be restored to its previous value. 117 | 118 | :: 119 | 120 | with override_prefetch_related_objects(prefetch_related_objects): 121 | dogs = list(Dog.objects.prefetch_related('toys')) 122 | 123 | .. note:: 124 | 125 | This requires :func:`enable_prefetch_related_objects_selector` to 126 | be run before the changes are able to take effect. 127 | """ 128 | 129 | def __init__(self, func): 130 | self.func = func 131 | self.original_value = None 132 | 133 | def __enter__(self): 134 | self.original_value = getattr(_active, "value", None) 135 | _active.value = self.func 136 | 137 | def __exit__(self, exc_type, exc_value, traceback): 138 | _active.value = self.original_value 139 | 140 | 141 | class use_original_prefetch_related_objects(override_prefetch_related_objects): 142 | """ 143 | This context decorator allows one to force the ``prefetch_related_objects`` 144 | implementation to be Django's default implementation:: 145 | 146 | with use_original_prefetch_related_objects(): 147 | dogs = list(Dog.objects.prefetch_related('toys')) 148 | 149 | """ 150 | 151 | def __init__(self): 152 | super().__init__(original_prefetch_related_objects) 153 | -------------------------------------------------------------------------------- /tests/apps_tests.py: -------------------------------------------------------------------------------- 1 | from django.apps import apps 2 | from django.test import TestCase 3 | from django.test import override_settings 4 | 5 | from django_prefetch_utils.identity_map import prefetch_related_objects as identity_map_prefetch_related_objects 6 | from django_prefetch_utils.selector import get_prefetch_related_objects 7 | from django_prefetch_utils.selector import original_prefetch_related_objects 8 | from django_prefetch_utils.selector import remove_default_prefetch_related_objects 9 | 10 | 11 | class DjangoPrefetchUtilsAppConfigTests(TestCase): 12 | def setUp(self): 13 | self.config = apps.get_app_config("django_prefetch_utils") 14 | self.addCleanup(remove_default_prefetch_related_objects) 15 | 16 | @override_settings( 17 | PREFETCH_UTILS_DEFAULT_IMPLEMENTATION="django_prefetch_utils.identity_map.prefetch_related_objects" 18 | ) 19 | def test_default_from_string(self): 20 | self.config.set_default_prefetch_related_objects_implementation() 21 | self.assertIs(get_prefetch_related_objects(), identity_map_prefetch_related_objects) 22 | 23 | @override_settings(PREFETCH_UTILS_DEFAULT_IMPLEMENTATION=identity_map_prefetch_related_objects) 24 | def test_default_from_object(self): 25 | self.config.set_default_prefetch_related_objects_implementation() 26 | self.assertIs(get_prefetch_related_objects(), identity_map_prefetch_related_objects) 27 | 28 | @override_settings(PREFETCH_UTILS_DEFAULT_IMPLEMENTATION=None) 29 | def test_default_no_setting(self): 30 | self.config.set_default_prefetch_related_objects_implementation() 31 | self.assertIs(get_prefetch_related_objects(), original_prefetch_related_objects) 32 | -------------------------------------------------------------------------------- /tests/backport_tests.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from importlib import import_module 4 | 5 | from django.test import TestCase 6 | from prefetch_related.models import Author 7 | from prefetch_related.models import Book 8 | 9 | from django_prefetch_utils.backport import prefetch_related_objects 10 | from django_prefetch_utils.selector import override_prefetch_related_objects 11 | 12 | 13 | class EnableBackportMixin(object): 14 | def setUp(self): 15 | super(EnableBackportMixin, self).setUp() 16 | cm = override_prefetch_related_objects(prefetch_related_objects) 17 | cm.__enter__() 18 | self.addCleanup(lambda: cm.__exit__(None, None, None)) 19 | 20 | 21 | class MiscellaneousTests(TestCase): 22 | @classmethod 23 | def setUpTestData(cls): 24 | cls.book = Book.objects.create(title="Poems") 25 | cls.author = Author.objects.create(name="Jane", first_book=cls.book) 26 | cls.book.authors.add(cls.author) 27 | 28 | def test_no_prefetches_are_done_with_no_model_instances(self): 29 | with self.assertNumQueries(0): 30 | prefetch_related_objects([], "authors") 31 | 32 | 33 | DJANGO_TEST_MODULES = [ 34 | "prefetch_related.tests", 35 | "prefetch_related.test_uuid", 36 | "prefetch_related.test_prefetch_related_objects", 37 | "foreign_object.test_empty_join", 38 | "foreign_object.test_agnostic_order_trimjoin", 39 | "foreign_object.test_forms", 40 | "foreign_object.tests", 41 | ] 42 | 43 | 44 | # Import all of the Django prefetch_related test cases and run them under 45 | # the identity_map implemention 46 | for mod_string in DJANGO_TEST_MODULES: 47 | mod = import_module(mod_string) 48 | for attr in dir(mod): 49 | cls = getattr(mod, attr) 50 | if not isinstance(cls, type) or not issubclass(cls, TestCase): 51 | continue 52 | if attr in globals(): 53 | continue 54 | 55 | new_cls = type(cls)("Backport{}".format(cls.__name__), (EnableBackportMixin, cls), {}) 56 | globals()["Backport{}".format(attr)] = new_cls 57 | del cls 58 | del new_cls 59 | del mod 60 | -------------------------------------------------------------------------------- /tests/descriptors_tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/roverdotcom/django-prefetch-utils/f901cb2159b3e95f44baf18812d5bbb7c52afd97/tests/descriptors_tests/__init__.py -------------------------------------------------------------------------------- /tests/descriptors_tests/annotation_tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from prefetch_related.models import Author 3 | 4 | from django_prefetch_utils.descriptors import AnnotationDescriptor 5 | 6 | from .mixins import GenericSingleObjectDescriptorTestCaseMixin 7 | from .models import BookWithAuthorCount 8 | 9 | 10 | class AnnotationDescriptorTests(GenericSingleObjectDescriptorTestCaseMixin, TestCase): 11 | 12 | supports_custom_querysets = False 13 | descriptor_class = AnnotationDescriptor 14 | attr = "authors_count" 15 | 16 | @classmethod 17 | def setUpTestData(cls): 18 | cls.book = BookWithAuthorCount.objects.create(title="Poems") 19 | cls.author = Author.objects.create(name="Jane", first_book=cls.book) 20 | cls.book.authors.add(cls.author) 21 | 22 | def get_object(self): 23 | return self.book 24 | 25 | @property 26 | def related_object(self): 27 | return 1 28 | -------------------------------------------------------------------------------- /tests/descriptors_tests/equal_fields_tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from prefetch_related.models import Author 3 | from prefetch_related.models import YearlyBio 4 | 5 | from django_prefetch_utils.descriptors import EqualFieldsDescriptor 6 | 7 | from .mixins import GenericQuerySetDescriptorTestCaseMixin 8 | from .models import BookWithYearlyBios 9 | from .models import XYZModelOne 10 | from .models import XYZModelTwo 11 | 12 | 13 | class EqualFieldsDescriptorWithOneJoinTests(GenericQuerySetDescriptorTestCaseMixin, TestCase): 14 | descriptor_class = EqualFieldsDescriptor 15 | attr = "bios" 16 | 17 | @classmethod 18 | def setUpTestData(cls): 19 | cls.first_book = BookWithYearlyBios.objects.create(title="Book One", published_year=1900) 20 | cls.second_book = BookWithYearlyBios.objects.create(title="Book Two", published_year=1980) 21 | cls.author = Author.objects.create(name="Jane", first_book=cls.first_book) 22 | cls.author2 = Author.objects.create(name="Anne", first_book=cls.first_book) 23 | cls.first_bio = YearlyBio.objects.create(author=cls.author, year=cls.first_book.published_year) 24 | cls.second_bio = YearlyBio.objects.create(author=cls.author2, year=cls.second_book.published_year) 25 | 26 | def get_object(self): 27 | return self.first_book 28 | 29 | def get_expected_related_objects(self): 30 | return [self.first_bio] 31 | 32 | 33 | class EqualFieldsDescriptorWithMultipleJoinsTests(GenericQuerySetDescriptorTestCaseMixin, TestCase): 34 | descriptor_class = EqualFieldsDescriptor 35 | attr = "ones" 36 | 37 | @classmethod 38 | def setUpTestData(cls): 39 | cls.one_a = XYZModelOne.objects.create(x=1, y=2, z="a") 40 | cls.one_b = XYZModelOne.objects.create(x=1, y=2, z="b") 41 | cls.two_a = XYZModelTwo.objects.create(x=1, y=2, z="a") 42 | cls.two_b = XYZModelTwo.objects.create(x=1, y=2, z="b") 43 | 44 | def get_object(self): 45 | return self.two_a 46 | 47 | def get_expected_related_objects(self): 48 | return [self.one_a] 49 | 50 | 51 | class EqualFieldsDescriptorWithCommonTests(TestCase): 52 | def setUp(self): 53 | super().setUp() 54 | self.descriptor = EqualFieldsDescriptor(XYZModelTwo, ["a"]) 55 | 56 | def test_preprocess_join_fields_single_string(self): 57 | self.assertEqual(self.descriptor.preprocess_join_fields("a"), [("a", "a")]) 58 | 59 | def test_preprocess_join_fields_list_of_strings(self): 60 | self.assertEqual(self.descriptor.preprocess_join_fields(["a", "b"]), [("a", "a"), ("b", "b")]) 61 | 62 | def test_preprocess_join_fields_list_of_tuples(self): 63 | self.assertEqual(self.descriptor.preprocess_join_fields([("a", "c"), ("b", "d")]), [("a", "c"), ("b", "d")]) 64 | 65 | def test_raises_error_if_no_join_fields_are_provided(self): 66 | with self.assertRaises(ValueError): 67 | EqualFieldsDescriptor(XYZModelTwo, []) 68 | -------------------------------------------------------------------------------- /tests/descriptors_tests/mixins.py: -------------------------------------------------------------------------------- 1 | import abc 2 | 3 | from django.db.models import F 4 | from django.db.models import Model 5 | from django.db.models import Prefetch 6 | 7 | 8 | class GenericPrefetchDescriptorTestCaseMixin(abc.ABC): 9 | supports_custom_querysets = True 10 | 11 | def setUp(self): 12 | super().setUp() 13 | self.obj = self.instance_queryset.get(pk=self.get_object().pk) 14 | 15 | @abc.abstractproperty 16 | def descriptor_class(self): 17 | pass 18 | 19 | @abc.abstractproperty 20 | def attr(self): 21 | pass 22 | 23 | @property 24 | def descriptor(self): 25 | return getattr(type(self.obj), self.attr) 26 | 27 | @abc.abstractmethod 28 | def get_expected_related_objects(self): 29 | pass 30 | 31 | @abc.abstractmethod 32 | def get_object(self): 33 | pass 34 | 35 | @property 36 | def instance_queryset(self): 37 | return type(self.get_object())._default_manager.all() 38 | 39 | @property 40 | def related_object_queryset(self): 41 | rel_obj = self.get_expected_related_objects()[0] 42 | return type(rel_obj)._default_manager.all() 43 | 44 | def get_prefetched_queryset(self): 45 | return self.instance_queryset.prefetch_related(self.attr) 46 | 47 | def fetch_obj(self): 48 | return self.get_prefetched_queryset().get(pk=self.obj.pk) 49 | 50 | def delete_related_objects(self): 51 | self.related_object_queryset.delete() 52 | 53 | def test_descriptor_is_correct_instance(self): 54 | self.assertIsInstance(self.descriptor, self.descriptor_class) 55 | 56 | def test_get_on_class_returns_descriptor(self): 57 | self.assertIsInstance(getattr(type(self.obj), self.attr), type(self.descriptor)) 58 | 59 | 60 | class GenericQuerySetDescriptorTestCaseMixin(GenericPrefetchDescriptorTestCaseMixin): 61 | @property 62 | def manager(self): 63 | return getattr(self.obj, self.attr) 64 | 65 | def test_expected_related_objects(self): 66 | self.assertEqual(sorted(self.manager.all()), sorted(self.get_expected_related_objects())) 67 | 68 | def test_get_on_instance_returns_manager(self): 69 | self.assertIsInstance(getattr(type(self.obj), self.attr), type(self.descriptor)) 70 | 71 | def test_get_prefetch_queryset_integration_test(self): 72 | prefetched_qs = getattr(self.fetch_obj(), self.attr).all() 73 | with self.assertNumQueries(0): 74 | self.assertEqual(sorted(prefetched_qs), sorted(self.get_expected_related_objects())) 75 | 76 | def test_get_prefetch_queryset_integration_test_custom_queryset(self): 77 | custom_queryset = self.related_object_queryset.annotate(test_annotation=F("pk")) 78 | obj = self.instance_queryset.prefetch_related(Prefetch(self.attr, queryset=custom_queryset)).get(pk=self.obj.pk) 79 | rel_qs = getattr(obj, self.attr).all() 80 | with self.assertNumQueries(0): 81 | prefetched_related_objects = sorted(rel_qs) 82 | self.assertEqual(prefetched_related_objects, sorted(self.get_expected_related_objects())) 83 | for rel_obj in prefetched_related_objects: 84 | self.assertEqual(rel_obj.test_annotation, rel_obj.pk) 85 | 86 | 87 | class GenericSingleObjectDescriptorTestCaseMixin(GenericPrefetchDescriptorTestCaseMixin): 88 | @abc.abstractproperty 89 | def related_object(self): 90 | pass 91 | 92 | def get_expected_related_objects(self): 93 | return [self.related_object] 94 | 95 | def test_get_on_instance_returns_related_object(self): 96 | self.assertEqual(getattr(self.obj, self.attr), self.related_object) 97 | 98 | def test_get_prefetch_queryset_integration_test(self): 99 | obj = self.fetch_obj() 100 | with self.assertNumQueries(0): 101 | self.assertEqual(getattr(obj, self.attr), self.related_object) 102 | 103 | def test_none_is_returned_if_there_is_no_related_object(self): 104 | if not isinstance(self.related_object, Model): 105 | return 106 | 107 | self.delete_related_objects() 108 | self.assertIsNone(getattr(self.obj, self.attr)) 109 | 110 | def test_none_is_returned_if_there_is_no_related_object_when_prefetched(self): 111 | if not isinstance(self.related_object, Model): 112 | return 113 | 114 | self.delete_related_objects() 115 | self.assertIsNone(getattr(self.fetch_obj(), self.attr)) 116 | 117 | def test_get_prefetch_queryset_integration_test_custom_queryset(self): 118 | if not self.supports_custom_querysets: 119 | return 120 | 121 | custom_queryset = self.related_object_queryset.annotate(test_annotation=F("pk")) 122 | obj = self.instance_queryset.prefetch_related(Prefetch(self.attr, queryset=custom_queryset)).get(pk=self.obj.pk) 123 | with self.assertNumQueries(0): 124 | rel_obj = getattr(obj, self.attr) 125 | self.assertEqual(rel_obj, self.related_object) 126 | self.assertEqual(rel_obj.test_annotation, rel_obj.pk) 127 | -------------------------------------------------------------------------------- /tests/descriptors_tests/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.contenttypes.fields import GenericForeignKey 2 | from django.contrib.contenttypes.fields import GenericRelation 3 | from django.contrib.contenttypes.models import ContentType 4 | from django.db import models 5 | from django.db.models import Count 6 | from prefetch_related.models import Author 7 | from prefetch_related.models import AuthorWithAge 8 | from prefetch_related.models import Book 9 | from prefetch_related.models import BookWithYear 10 | from prefetch_related.models import Reader 11 | 12 | from django_prefetch_utils.descriptors import AnnotationDescriptor 13 | from django_prefetch_utils.descriptors import EqualFieldsDescriptor 14 | from django_prefetch_utils.descriptors import RelatedQuerySetDescriptorViaLookup 15 | from django_prefetch_utils.descriptors import RelatedSingleObjectDescriptorViaLookup 16 | from django_prefetch_utils.descriptors import TopChildDescriptorFromField 17 | from django_prefetch_utils.descriptors import TopChildDescriptorFromGenericRelation 18 | 19 | 20 | class Comment(models.Model): 21 | comment = models.TextField() 22 | 23 | # Content-object field 24 | content_type = models.ForeignKey(ContentType, models.CASCADE) 25 | object_pk = models.PositiveIntegerField() 26 | content_object = GenericForeignKey(ct_field="content_type", fk_field="object_pk") 27 | 28 | class Meta: 29 | ordering = ["id"] 30 | 31 | 32 | class BookWithAuthorCount(Book): 33 | class Meta(object): 34 | proxy = True 35 | 36 | authors_count = AnnotationDescriptor(Count("authors")) 37 | comments = GenericRelation(Comment, object_id_field="object_pk") 38 | 39 | latest_comment = TopChildDescriptorFromGenericRelation(comments, order_by=("-id",)) 40 | 41 | 42 | class ReaderWithAuthorsRead(Reader): 43 | class Meta(object): 44 | proxy = True 45 | 46 | authors_read = RelatedQuerySetDescriptorViaLookup(Author, "books__read_by") 47 | an_author_read = RelatedSingleObjectDescriptorViaLookup("prefetch_related.Author", "books__read_by") 48 | 49 | 50 | class AuthorWithLastBook(AuthorWithAge): 51 | last_book = TopChildDescriptorFromField("prefetch_related.BookWithYear.aged_authors", order_by=("-published_year",)) 52 | 53 | 54 | class BookWithYearlyBios(BookWithYear): 55 | bios = EqualFieldsDescriptor("prefetch_related.YearlyBio", [("published_year", "year")]) 56 | 57 | 58 | class XYZModelOne(models.Model): 59 | x = models.IntegerField() 60 | y = models.IntegerField() 61 | z = models.CharField(max_length=10) 62 | 63 | 64 | class XYZModelTwo(models.Model): 65 | x = models.IntegerField() 66 | y = models.IntegerField() 67 | z = models.CharField(max_length=10) 68 | 69 | ones = EqualFieldsDescriptor(XYZModelOne, ["x", "y", "z"]) 70 | -------------------------------------------------------------------------------- /tests/descriptors_tests/top_child_tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from prefetch_related.models import BookWithYear 3 | 4 | from django_prefetch_utils.descriptors import TopChildDescriptorFromField 5 | from django_prefetch_utils.descriptors import TopChildDescriptorFromGenericRelation 6 | 7 | from .mixins import GenericSingleObjectDescriptorTestCaseMixin 8 | from .models import AuthorWithLastBook 9 | from .models import BookWithAuthorCount 10 | from .models import Comment 11 | 12 | 13 | class TopChildDescriptorFromFieldTests(GenericSingleObjectDescriptorTestCaseMixin, TestCase): 14 | descriptor_class = TopChildDescriptorFromField 15 | attr = "last_book" 16 | 17 | @classmethod 18 | def setUpTestData(cls): 19 | cls.first_book = BookWithYear.objects.create(title="Book One", published_year=1900) 20 | cls.second_book = BookWithYear.objects.create(title="Book Two", published_year=1980) 21 | cls.author = AuthorWithLastBook.objects.create(name="Jane", age=20, first_book=cls.first_book) 22 | cls.author.books_with_year.add(cls.first_book, cls.second_book) 23 | 24 | def get_object(self): 25 | return self.author 26 | 27 | @property 28 | def related_object(self): 29 | return self.second_book 30 | 31 | def delete_related_objects(self): 32 | self.author.books_with_year.clear() 33 | 34 | 35 | class TopChildDescriptorFromGenericRelationTests(GenericSingleObjectDescriptorTestCaseMixin, TestCase): 36 | descriptor_class = TopChildDescriptorFromGenericRelation 37 | attr = "latest_comment" 38 | 39 | @classmethod 40 | def setUpTestData(cls): 41 | cls.book = BookWithAuthorCount.objects.create(title="Book One") 42 | cls.first_comment = Comment.objects.create(comment="First", content_object=cls.book) 43 | cls.second_comment = Comment.objects.create(comment="First", content_object=cls.book) 44 | 45 | def get_object(self): 46 | return self.book 47 | 48 | @property 49 | def related_object(self): 50 | return self.second_comment 51 | 52 | def test_get_child_model(self): 53 | self.assertEqual(self.descriptor.get_child_model(), Comment) 54 | 55 | def test_get_parent_model(self): 56 | self.assertEqual(self.descriptor.get_parent_model(), type(self.obj)) 57 | -------------------------------------------------------------------------------- /tests/descriptors_tests/via_lookup_tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from prefetch_related.models import Author 3 | from prefetch_related.models import Book 4 | 5 | from django_prefetch_utils.descriptors import RelatedQuerySetDescriptorViaLookup 6 | from django_prefetch_utils.descriptors import RelatedSingleObjectDescriptorViaLookup 7 | 8 | from .mixins import GenericQuerySetDescriptorTestCaseMixin 9 | from .mixins import GenericSingleObjectDescriptorTestCaseMixin 10 | from .models import ReaderWithAuthorsRead 11 | 12 | 13 | class RelatedQuerySetDescriptorViaLookupTests(GenericQuerySetDescriptorTestCaseMixin, TestCase): 14 | 15 | descriptor_class = RelatedQuerySetDescriptorViaLookup 16 | 17 | @classmethod 18 | def setUpTestData(cls): 19 | cls.book = Book.objects.create(title="Poems") 20 | cls.author = Author.objects.create(name="Jane", first_book=cls.book) 21 | cls.book.authors.add(cls.author) 22 | cls.reader = ReaderWithAuthorsRead.objects.create(name="A. Reader") 23 | cls.reader.books_read.add(cls.book) 24 | 25 | def get_object(self): 26 | return self.reader 27 | 28 | @property 29 | def attr(self): 30 | return "authors_read" 31 | 32 | def test_lookup(self): 33 | self.assertEqual(self.descriptor.lookup, "books__read_by") 34 | 35 | def test_get_prefetch_model_class(self): 36 | self.assertEqual(self.descriptor.get_prefetch_model_class(), Author) 37 | 38 | def get_expected_related_objects(self): 39 | return [self.author] 40 | 41 | 42 | class RelatedSingleObjectDescriptorViaLookupTests(GenericSingleObjectDescriptorTestCaseMixin, TestCase): 43 | 44 | descriptor_class = RelatedSingleObjectDescriptorViaLookup 45 | attr = "an_author_read" 46 | 47 | @classmethod 48 | def setUpTestData(cls): 49 | cls.book = Book.objects.create(title="Poems") 50 | cls.author = Author.objects.create(name="Jane", first_book=cls.book) 51 | cls.book.authors.add(cls.author) 52 | cls.reader = ReaderWithAuthorsRead.objects.create(name="A. Reader") 53 | cls.reader.books_read.add(cls.book) 54 | 55 | def get_object(self): 56 | return self.reader 57 | 58 | @property 59 | def related_object(self): 60 | return self.author 61 | 62 | def test_lookup(self): 63 | self.assertEqual(self.descriptor.lookup, "books__read_by") 64 | 65 | def test_get_prefetch_model_class(self): 66 | self.assertEqual(self.descriptor.get_prefetch_model_class(), Author) 67 | -------------------------------------------------------------------------------- /tests/foreign_object/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/roverdotcom/django-prefetch-utils/f901cb2159b3e95f44baf18812d5bbb7c52afd97/tests/foreign_object/__init__.py -------------------------------------------------------------------------------- /tests/foreign_object/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .article import Article 2 | from .article import ArticleIdea 3 | from .article import ArticleTag 4 | from .article import ArticleTranslation 5 | from .article import NewsArticle 6 | from .customers import Address 7 | from .customers import Contact 8 | from .customers import Customer 9 | from .empty_join import SlugPage 10 | from .person import Country 11 | from .person import Friendship 12 | from .person import Group 13 | from .person import Membership 14 | from .person import Person 15 | 16 | __all__ = [ 17 | "Address", 18 | "Article", 19 | "ArticleIdea", 20 | "ArticleTag", 21 | "ArticleTranslation", 22 | "Contact", 23 | "Country", 24 | "Customer", 25 | "Friendship", 26 | "Group", 27 | "Membership", 28 | "NewsArticle", 29 | "Person", 30 | "SlugPage", 31 | ] 32 | -------------------------------------------------------------------------------- /tests/foreign_object/models/article.py: -------------------------------------------------------------------------------- 1 | import django 2 | from django.db import models 3 | from django.db.models.fields.related import ForwardManyToOneDescriptor 4 | from django.utils.translation import get_language 5 | 6 | 7 | class ArticleTranslationDescriptor(ForwardManyToOneDescriptor): 8 | """ 9 | The set of articletranslation should not set any local fields. 10 | """ 11 | 12 | def __set__(self, instance, value): 13 | if instance is None: 14 | raise AttributeError("%s must be accessed via instance" % self.field.name) 15 | self.field.set_cached_value(instance, value) 16 | if value is not None and not self.field.remote_field.multiple: 17 | self.field.remote_field.set_cached_value(value, instance) 18 | 19 | 20 | class ColConstraint: 21 | # Anything with as_sql() method works in get_extra_restriction(). 22 | def __init__(self, alias, col, value): 23 | self.alias, self.col, self.value = alias, col, value 24 | 25 | def as_sql(self, compiler, connection): 26 | qn = compiler.quote_name_unless_alias 27 | return "%s.%s = %%s" % (qn(self.alias), qn(self.col)), [self.value] 28 | 29 | 30 | class ActiveTranslationField(models.ForeignObject): 31 | """ 32 | This field will allow querying and fetching the currently active translation 33 | for Article from ArticleTranslation. 34 | """ 35 | 36 | requires_unique_target = False 37 | 38 | if django.VERSION < (4, 0): 39 | 40 | def get_extra_restriction(self, where_class, alias, related_alias): 41 | return ColConstraint(alias, "lang", get_language()) 42 | 43 | else: 44 | 45 | def get_extra_restriction(self, alias, related_alias): 46 | return ColConstraint(alias, "lang", get_language()) 47 | 48 | def get_extra_descriptor_filter(self, instance): 49 | return {"lang": get_language()} 50 | 51 | def contribute_to_class(self, cls, name): 52 | super().contribute_to_class(cls, name) 53 | setattr(cls, self.name, ArticleTranslationDescriptor(self)) 54 | 55 | 56 | class ActiveTranslationFieldWithQ(ActiveTranslationField): 57 | def get_extra_descriptor_filter(self, instance): 58 | return models.Q(lang=get_language()) 59 | 60 | 61 | class Article(models.Model): 62 | active_translation = ActiveTranslationField( 63 | "ArticleTranslation", 64 | from_fields=["id"], 65 | to_fields=["article"], 66 | related_name="+", 67 | on_delete=models.CASCADE, 68 | null=True, 69 | ) 70 | active_translation_q = ActiveTranslationFieldWithQ( 71 | "ArticleTranslation", 72 | from_fields=["id"], 73 | to_fields=["article"], 74 | related_name="+", 75 | on_delete=models.CASCADE, 76 | null=True, 77 | ) 78 | pub_date = models.DateField() 79 | 80 | def __str__(self): 81 | try: 82 | return self.active_translation.title 83 | except ArticleTranslation.DoesNotExist: 84 | return "[No translation found]" 85 | 86 | 87 | class NewsArticle(Article): 88 | pass 89 | 90 | 91 | class ArticleTranslation(models.Model): 92 | article = models.ForeignKey(Article, models.CASCADE) 93 | lang = models.CharField(max_length=2) 94 | title = models.CharField(max_length=100) 95 | body = models.TextField() 96 | abstract = models.TextField(null=True) 97 | 98 | class Meta: 99 | unique_together = ("article", "lang") 100 | 101 | 102 | class ArticleTag(models.Model): 103 | article = models.ForeignKey(Article, models.CASCADE, related_name="tags", related_query_name="tag") 104 | name = models.CharField(max_length=255) 105 | 106 | 107 | class ArticleIdea(models.Model): 108 | articles = models.ManyToManyField(Article, related_name="ideas", related_query_name="idea_things") 109 | name = models.CharField(max_length=255) 110 | -------------------------------------------------------------------------------- /tests/foreign_object/models/customers.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.db.models.fields.related import ForeignObject 3 | 4 | 5 | class Address(models.Model): 6 | company = models.CharField(max_length=1) 7 | customer_id = models.IntegerField() 8 | 9 | class Meta: 10 | unique_together = [("company", "customer_id")] 11 | 12 | 13 | class Customer(models.Model): 14 | company = models.CharField(max_length=1) 15 | customer_id = models.IntegerField() 16 | address = ForeignObject( 17 | Address, 18 | models.CASCADE, 19 | null=True, 20 | # order mismatches the Contact ForeignObject. 21 | from_fields=["company", "customer_id"], 22 | to_fields=["company", "customer_id"], 23 | ) 24 | 25 | class Meta: 26 | unique_together = [("company", "customer_id")] 27 | 28 | 29 | class Contact(models.Model): 30 | company_code = models.CharField(max_length=1) 31 | customer_code = models.IntegerField() 32 | customer = ForeignObject( 33 | Customer, 34 | models.CASCADE, 35 | related_name="contacts", 36 | to_fields=["customer_id", "company"], 37 | from_fields=["customer_code", "company_code"], 38 | ) 39 | -------------------------------------------------------------------------------- /tests/foreign_object/models/empty_join.py: -------------------------------------------------------------------------------- 1 | import django 2 | from django.db import models 3 | from django.db.models.fields.related import ForeignObjectRel 4 | from django.db.models.fields.related import ReverseManyToOneDescriptor 5 | from django.db.models.lookups import StartsWith 6 | from django.db.models.query_utils import PathInfo 7 | 8 | 9 | class CustomForeignObjectRel(ForeignObjectRel): 10 | """ 11 | Define some extra Field methods so this Rel acts more like a Field, which 12 | lets us use ReverseManyToOneDescriptor in both directions. 13 | """ 14 | 15 | @property 16 | def foreign_related_fields(self): 17 | return tuple(lhs_field for lhs_field, rhs_field in self.field.related_fields) 18 | 19 | def get_attname(self): 20 | return self.name 21 | 22 | 23 | class StartsWithRelation(models.ForeignObject): 24 | """ 25 | A ForeignObject that uses StartsWith operator in its joins instead of 26 | the default equality operator. This is logically a many-to-many relation 27 | and creates a ReverseManyToOneDescriptor in both directions. 28 | """ 29 | 30 | auto_created = False 31 | 32 | many_to_many = False 33 | many_to_one = True 34 | one_to_many = False 35 | one_to_one = False 36 | 37 | rel_class = CustomForeignObjectRel 38 | 39 | def __init__(self, *args, **kwargs): 40 | kwargs["on_delete"] = models.DO_NOTHING 41 | super().__init__(*args, **kwargs) 42 | 43 | @property 44 | def field(self): 45 | """ 46 | Makes ReverseManyToOneDescriptor work in both directions. 47 | """ 48 | return self.remote_field 49 | 50 | if django.VERSION < (4, 0): 51 | 52 | def get_extra_restriction(self, where_class, alias, related_alias): 53 | to_field = self.remote_field.model._meta.get_field(self.to_fields[0]) 54 | from_field = self.model._meta.get_field(self.from_fields[0]) 55 | return StartsWith(to_field.get_col(alias), from_field.get_col(related_alias)) 56 | 57 | else: 58 | 59 | def get_extra_restriction(self, alias, related_alias): 60 | to_field = self.remote_field.model._meta.get_field(self.to_fields[0]) 61 | from_field = self.model._meta.get_field(self.from_fields[0]) 62 | return StartsWith(to_field.get_col(alias), from_field.get_col(related_alias)) 63 | 64 | def get_joining_columns(self, reverse_join=False): 65 | return () 66 | 67 | def get_path_info(self, filtered_relation=None): 68 | to_opts = self.remote_field.model._meta 69 | from_opts = self.model._meta 70 | return [ 71 | PathInfo( 72 | from_opts=from_opts, 73 | to_opts=to_opts, 74 | target_fields=(to_opts.pk,), 75 | join_field=self, 76 | m2m=False, 77 | direct=False, 78 | filtered_relation=filtered_relation, 79 | ) 80 | ] 81 | 82 | def get_reverse_path_info(self, filtered_relation=None): 83 | to_opts = self.model._meta 84 | from_opts = self.remote_field.model._meta 85 | return [ 86 | PathInfo( 87 | from_opts=from_opts, 88 | to_opts=to_opts, 89 | target_fields=(to_opts.pk,), 90 | join_field=self.remote_field, 91 | m2m=False, 92 | direct=False, 93 | filtered_relation=filtered_relation, 94 | ) 95 | ] 96 | 97 | def contribute_to_class(self, cls, name, private_only=False): 98 | super().contribute_to_class(cls, name, private_only) 99 | setattr(cls, self.name, ReverseManyToOneDescriptor(self)) 100 | 101 | 102 | class BrokenContainsRelation(StartsWithRelation): 103 | """ 104 | This model is designed to yield no join conditions and 105 | raise an exception in ``Join.as_sql()``. 106 | """ 107 | 108 | if django.VERSION < (4, 0): 109 | 110 | def get_extra_restriction(self, where_class, alias, related_alias): 111 | return None 112 | 113 | else: 114 | 115 | def get_extra_restriction(self, alias, related_alias): 116 | return None 117 | 118 | 119 | class SlugPage(models.Model): 120 | slug = models.CharField(max_length=20, unique=True) 121 | descendants = StartsWithRelation("self", from_fields=["slug"], to_fields=["slug"], related_name="ascendants") 122 | containers = BrokenContainsRelation("self", from_fields=["slug"], to_fields=["slug"]) 123 | 124 | class Meta: 125 | ordering = ["slug"] 126 | 127 | def __str__(self): 128 | return "SlugPage %s" % self.slug 129 | -------------------------------------------------------------------------------- /tests/foreign_object/models/person.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from django.db import models 4 | 5 | 6 | class Country(models.Model): 7 | # Table Column Fields 8 | name = models.CharField(max_length=50) 9 | 10 | def __str__(self): 11 | return self.name 12 | 13 | 14 | class Person(models.Model): 15 | # Table Column Fields 16 | name = models.CharField(max_length=128) 17 | person_country_id = models.IntegerField() 18 | 19 | # Relation Fields 20 | person_country = models.ForeignObject( 21 | Country, from_fields=["person_country_id"], to_fields=["id"], on_delete=models.CASCADE 22 | ) 23 | friends = models.ManyToManyField("self", through="Friendship", symmetrical=False) 24 | 25 | class Meta: 26 | ordering = ("name",) 27 | 28 | def __str__(self): 29 | return self.name 30 | 31 | 32 | class Group(models.Model): 33 | # Table Column Fields 34 | name = models.CharField(max_length=128) 35 | group_country = models.ForeignKey(Country, models.CASCADE) 36 | members = models.ManyToManyField(Person, related_name="groups", through="Membership") 37 | 38 | class Meta: 39 | ordering = ("name",) 40 | 41 | def __str__(self): 42 | return self.name 43 | 44 | 45 | class Membership(models.Model): 46 | # Table Column Fields 47 | membership_country = models.ForeignKey(Country, models.CASCADE) 48 | date_joined = models.DateTimeField(default=datetime.datetime.now) 49 | invite_reason = models.CharField(max_length=64, null=True) 50 | person_id = models.IntegerField() 51 | group_id = models.IntegerField(blank=True, null=True) 52 | 53 | # Relation Fields 54 | person = models.ForeignObject( 55 | Person, 56 | from_fields=["person_id", "membership_country"], 57 | to_fields=["id", "person_country_id"], 58 | on_delete=models.CASCADE, 59 | ) 60 | group = models.ForeignObject( 61 | Group, 62 | from_fields=["group_id", "membership_country"], 63 | to_fields=["id", "group_country"], 64 | on_delete=models.CASCADE, 65 | ) 66 | 67 | class Meta: 68 | ordering = ("date_joined", "invite_reason") 69 | 70 | def __str__(self): 71 | group_name = self.group.name if self.group_id else "NULL" 72 | return "%s is a member of %s" % (self.person.name, group_name) 73 | 74 | 75 | class Friendship(models.Model): 76 | # Table Column Fields 77 | from_friend_country = models.ForeignKey(Country, models.CASCADE, related_name="from_friend_country") 78 | from_friend_id = models.IntegerField() 79 | to_friend_country_id = models.IntegerField() 80 | to_friend_id = models.IntegerField() 81 | 82 | # Relation Fields 83 | from_friend = models.ForeignObject( 84 | Person, 85 | on_delete=models.CASCADE, 86 | from_fields=["from_friend_country", "from_friend_id"], 87 | to_fields=["person_country_id", "id"], 88 | related_name="from_friend", 89 | ) 90 | 91 | to_friend_country = models.ForeignObject( 92 | Country, 93 | from_fields=["to_friend_country_id"], 94 | to_fields=["id"], 95 | related_name="to_friend_country", 96 | on_delete=models.CASCADE, 97 | ) 98 | 99 | to_friend = models.ForeignObject( 100 | Person, 101 | from_fields=["to_friend_country_id", "to_friend_id"], 102 | to_fields=["person_country_id", "id"], 103 | related_name="to_friend", 104 | on_delete=models.CASCADE, 105 | ) 106 | -------------------------------------------------------------------------------- /tests/foreign_object/test_agnostic_order_trimjoin.py: -------------------------------------------------------------------------------- 1 | from operator import attrgetter 2 | 3 | from django.test.testcases import TestCase 4 | 5 | from .models import Address 6 | from .models import Contact 7 | from .models import Customer 8 | 9 | 10 | class TestLookupQuery(TestCase): 11 | @classmethod 12 | def setUpTestData(cls): 13 | cls.address = Address.objects.create(company=1, customer_id=20) 14 | cls.customer1 = Customer.objects.create(company=1, customer_id=20) 15 | cls.contact1 = Contact.objects.create(company_code=1, customer_code=20) 16 | 17 | def test_deep_mixed_forward(self): 18 | self.assertQuerysetEqual( 19 | Address.objects.filter(customer__contacts=self.contact1), [self.address.id], attrgetter("id") 20 | ) 21 | 22 | def test_deep_mixed_backward(self): 23 | self.assertQuerysetEqual( 24 | Contact.objects.filter(customer__address=self.address), [self.contact1.id], attrgetter("id") 25 | ) 26 | -------------------------------------------------------------------------------- /tests/foreign_object/test_empty_join.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from .models import SlugPage 4 | 5 | 6 | class RestrictedConditionsTests(TestCase): 7 | @classmethod 8 | def setUpTestData(cls): 9 | slugs = ["a", "a/a", "a/b", "a/b/a", "x", "x/y/z"] 10 | SlugPage.objects.bulk_create([SlugPage(slug=slug) for slug in slugs]) 11 | 12 | def test_restrictions_with_no_joining_columns(self): 13 | """ 14 | It's possible to create a working related field that doesn't 15 | use any joining columns, as long as an extra restriction is supplied. 16 | """ 17 | a = SlugPage.objects.get(slug="a") 18 | self.assertEqual([p.slug for p in SlugPage.objects.filter(ascendants=a)], ["a", "a/a", "a/b", "a/b/a"]) 19 | self.assertEqual([p.slug for p in a.descendants.all()], ["a", "a/a", "a/b", "a/b/a"]) 20 | 21 | aba = SlugPage.objects.get(slug="a/b/a") 22 | self.assertEqual([p.slug for p in SlugPage.objects.filter(descendants__in=[aba])], ["a", "a/b", "a/b/a"]) 23 | self.assertEqual([p.slug for p in aba.ascendants.all()], ["a", "a/b", "a/b/a"]) 24 | 25 | def test_empty_join_conditions(self): 26 | x = SlugPage.objects.get(slug="x") 27 | message = "Join generated an empty ON clause." 28 | with self.assertRaisesMessage(ValueError, message): 29 | list(SlugPage.objects.filter(containers=x)) 30 | -------------------------------------------------------------------------------- /tests/foreign_object/test_forms.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from django import forms 4 | from django.test import TestCase 5 | 6 | from .models import Article 7 | 8 | 9 | class FormsTests(TestCase): 10 | # ForeignObjects should not have any form fields, currently the user needs 11 | # to manually deal with the foreignobject relation. 12 | class ArticleForm(forms.ModelForm): 13 | class Meta: 14 | model = Article 15 | fields = "__all__" 16 | 17 | def test_foreign_object_form(self): 18 | # A very crude test checking that the non-concrete fields do not get form fields. 19 | form = FormsTests.ArticleForm() 20 | self.assertIn("id_pub_date", form.as_table()) 21 | self.assertNotIn("active_translation", form.as_table()) 22 | form = FormsTests.ArticleForm(data={"pub_date": str(datetime.date.today())}) 23 | self.assertTrue(form.is_valid()) 24 | a = form.save() 25 | self.assertEqual(a.pub_date, datetime.date.today()) 26 | form = FormsTests.ArticleForm(instance=a, data={"pub_date": "2013-01-01"}) 27 | a2 = form.save() 28 | self.assertEqual(a.pk, a2.pk) 29 | self.assertEqual(a2.pub_date, datetime.date(2013, 1, 1)) 30 | -------------------------------------------------------------------------------- /tests/identity_map/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/roverdotcom/django-prefetch-utils/f901cb2159b3e95f44baf18812d5bbb7c52afd97/tests/identity_map/__init__.py -------------------------------------------------------------------------------- /tests/identity_map/django_tests.py: -------------------------------------------------------------------------------- 1 | from importlib import import_module 2 | 3 | from django.db.models import Prefetch 4 | from django.test import TestCase 5 | from prefetch_related.models import Bookmark 6 | from prefetch_related.models import Employee 7 | from prefetch_related.models import House 8 | from prefetch_related.models import Person 9 | from prefetch_related.models import TaggedItem 10 | from prefetch_related.tests import CustomPrefetchTests 11 | from prefetch_related.tests import NullableTest 12 | 13 | from .mixins import EnableIdentityMapMixin 14 | 15 | 16 | class IdentityMapCustomPrefetchTests(EnableIdentityMapMixin, CustomPrefetchTests): 17 | def test_custom_qs_inner_select_related(self): 18 | # Test inner select_related. 19 | with self.assertNumQueries(2): 20 | lst1 = list(Person.objects.prefetch_related("houses__owner")) 21 | with self.assertNumQueries(2): 22 | lst2 = list( 23 | Person.objects.prefetch_related(Prefetch("houses", queryset=House.objects.select_related("owner"))) 24 | ) 25 | self.assertEqual(self.traverse_qs(lst1, [["houses", "owner"]]), self.traverse_qs(lst2, [["houses", "owner"]])) 26 | 27 | def test_traverse_single_item_property(self): 28 | # Control lookups. 29 | with self.assertNumQueries(4): 30 | lst1 = self.traverse_qs( 31 | Person.objects.prefetch_related("houses__rooms", "primary_house__occupants__houses"), 32 | [["primary_house", "occupants", "houses"]], 33 | ) 34 | 35 | # Test lookups. 36 | with self.assertNumQueries(4): 37 | lst2 = self.traverse_qs( 38 | Person.objects.prefetch_related( 39 | "houses__rooms", 40 | Prefetch("primary_house__occupants", to_attr="occupants_lst"), 41 | "primary_house__occupants_lst__houses", 42 | ), 43 | [["primary_house", "occupants_lst", "houses"]], 44 | ) 45 | self.assertEqual(lst1, lst2) 46 | 47 | def test_traverse_multiple_items_property(self): 48 | # Control lookups. 49 | with self.assertNumQueries(3): 50 | lst1 = self.traverse_qs( 51 | Person.objects.prefetch_related("houses", "all_houses__occupants__houses"), 52 | [["all_houses", "occupants", "houses"]], 53 | ) 54 | 55 | # Test lookups. 56 | with self.assertNumQueries(3): 57 | lst2 = self.traverse_qs( 58 | Person.objects.prefetch_related( 59 | "houses", 60 | Prefetch("all_houses__occupants", to_attr="occupants_lst"), 61 | "all_houses__occupants_lst__houses", 62 | ), 63 | [["all_houses", "occupants_lst", "houses"]], 64 | ) 65 | self.assertEqual(lst1, lst2) 66 | 67 | def test_generic_rel(self): 68 | bookmark = Bookmark.objects.create(url="http://www.djangoproject.com/") 69 | TaggedItem.objects.create(content_object=bookmark, tag="django") 70 | TaggedItem.objects.create(content_object=bookmark, favorite=bookmark, tag="python") 71 | 72 | # Control lookups. 73 | with self.assertNumQueries(3): 74 | lst1 = self.traverse_qs( 75 | Bookmark.objects.prefetch_related("tags", "tags__content_object", "favorite_tags"), 76 | [["tags", "content_object"], ["favorite_tags"]], 77 | ) 78 | 79 | # Test lookups. 80 | with self.assertNumQueries(3): 81 | lst2 = self.traverse_qs( 82 | Bookmark.objects.prefetch_related( 83 | Prefetch("tags", to_attr="tags_lst"), 84 | Prefetch("tags_lst__content_object"), 85 | Prefetch("favorite_tags"), 86 | ), 87 | [["tags_lst", "content_object"], ["favorite_tags"]], 88 | ) 89 | self.assertEqual(lst1, lst2) 90 | 91 | 92 | class IdentityMapNullableTest(EnableIdentityMapMixin, NullableTest): 93 | def test_prefetch_nullable(self): 94 | # One for main employee, one for boss, one for serfs 95 | with self.assertNumQueries(2): 96 | qs = Employee.objects.prefetch_related("boss__serfs") 97 | co_serfs = [list(e.boss.serfs.all()) if e.boss is not None else [] for e in qs] 98 | 99 | qs2 = Employee.objects.all() 100 | co_serfs2 = [list(e.boss.serfs.all()) if e.boss is not None else [] for e in qs2] 101 | 102 | self.assertEqual(co_serfs, co_serfs2) 103 | 104 | 105 | DJANGO_TEST_MODULES = [ 106 | "prefetch_related.tests", 107 | "prefetch_related.test_uuid", 108 | "prefetch_related.test_prefetch_related_objects", 109 | "foreign_object.test_empty_join", 110 | "foreign_object.test_agnostic_order_trimjoin", 111 | "foreign_object.test_forms", 112 | "foreign_object.tests", 113 | ] 114 | 115 | 116 | # Import all of the Django prefetch_related test cases and run them under 117 | # the identity_map implemention 118 | for mod_string in DJANGO_TEST_MODULES: 119 | mod = import_module(mod_string) 120 | for attr in dir(mod): 121 | cls = getattr(mod, attr) 122 | if not isinstance(cls, type) or not issubclass(cls, TestCase): 123 | continue 124 | if attr in globals(): 125 | continue 126 | 127 | new_cls = type(cls)("IdentityMap{}".format(cls.__name__), (EnableIdentityMapMixin, cls), {}) 128 | globals()["IdentityMap{}".format(attr)] = new_cls 129 | del cls 130 | del new_cls 131 | del mod 132 | 133 | del CustomPrefetchTests 134 | del NullableTest 135 | -------------------------------------------------------------------------------- /tests/identity_map/integration_tests.py: -------------------------------------------------------------------------------- 1 | from django.contrib.contenttypes.models import ContentType 2 | from django.db.models import Count 3 | from django.db.models import F 4 | from django.db.models import Prefetch 5 | from django.test import TestCase 6 | from prefetch_related.models import Author 7 | from prefetch_related.models import Bio 8 | from prefetch_related.models import Book 9 | from prefetch_related.models import Bookmark 10 | from prefetch_related.models import DirectBio 11 | from prefetch_related.models import FavoriteAuthors 12 | from prefetch_related.models import Person 13 | from prefetch_related.models import TaggedItem 14 | from prefetch_related.models import YearlyBio 15 | 16 | from django_prefetch_utils.identity_map.persistent import use_persistent_prefetch_identity_map 17 | 18 | from .mixins import EnableIdentityMapMixin 19 | 20 | 21 | class ForwardDescriptorTestsMixin(EnableIdentityMapMixin): 22 | bio_class = None 23 | reverse_attr = None 24 | 25 | @classmethod 26 | def setUpTestData(cls): 27 | cls.book = Book.objects.create(title="Poems") 28 | cls.author = Author.objects.create(name="Jane", first_book=cls.book) 29 | cls.bio = cls.bio_class.objects.create(**cls.create_bio_kwargs(author=cls.author)) 30 | 31 | @classmethod 32 | def create_bio_kwargs(cls, **kwargs): 33 | return kwargs 34 | 35 | def test_reverse_is_correctly_set(self): 36 | if not self.reverse_attr: 37 | return 38 | 39 | with self.assertNumQueries(2): 40 | bio = self.bio_class.objects.prefetch_related("author").first() 41 | self.assertIs(getattr(bio.author, self.reverse_attr), bio) 42 | 43 | @use_persistent_prefetch_identity_map(pass_identity_map=True) 44 | def test_no_additional_queries_if_related_object_in_identity_map(self, identity_map): 45 | author = identity_map[Author.objects.first()] 46 | with self.assertNumQueries(1): 47 | bio = self.bio_class.objects.prefetch_related("author").first() 48 | self.assertIs(bio.author, author) 49 | 50 | @use_persistent_prefetch_identity_map(pass_identity_map=True) 51 | def test_annotations(self, identity_map): 52 | # Test that annotations from Prefetch.queryset are applied even 53 | # if the prefetched object already exists in the identity map 54 | author = identity_map[Author.objects.first()] 55 | with self.assertNumQueries(2): 56 | bio = self.bio_class.objects.prefetch_related( 57 | Prefetch("author", queryset=Author.objects.annotate(double_id=2 * F("id"))) 58 | ).first() 59 | self.assertIs(bio.author, author) 60 | self.assertEqual(bio.author.double_id, 2 * author.id) 61 | 62 | @use_persistent_prefetch_identity_map(pass_identity_map=True) 63 | def test_select_related(self, identity_map): 64 | # Test that annotations from Prefetch.queryset are applied even 65 | # if the prefetched object already exists in the identity map 66 | author = identity_map[Author.objects.first()] 67 | with self.assertNumQueries(2): 68 | bio = self.bio_class.objects.prefetch_related( 69 | Prefetch("author", queryset=Author.objects.select_related("first_book")) 70 | ).first() 71 | 72 | # Check to make sure that the author is the one in the identity 73 | # map and that that it has the select_related object 74 | self.assertIs(bio.author, author) 75 | with self.assertNumQueries(0): 76 | self.assertEqual(author.first_book.id, author.first_book_id) 77 | 78 | 79 | class ForwardDescriptorTests(ForwardDescriptorTestsMixin, TestCase): 80 | bio_class = DirectBio 81 | reverse_attr = "direct_bio" 82 | 83 | 84 | class ForwardDescriptorWithToFieldTests(ForwardDescriptorTestsMixin, TestCase): 85 | bio_class = Bio 86 | reverse_attr = "bio" 87 | 88 | 89 | class ForwardDescriptorManyToOneTests(ForwardDescriptorTestsMixin, TestCase): 90 | bio_class = YearlyBio 91 | 92 | @classmethod 93 | def create_bio_kwargs(cls, **kwargs): 94 | return dict({"year": 2019}, **kwargs) 95 | 96 | 97 | class ReverseOneToOneTests(EnableIdentityMapMixin, TestCase): 98 | @classmethod 99 | def setUpTestData(cls): 100 | cls.book = Book.objects.create(title="Poems") 101 | cls.author = Author.objects.create(name="Jane", first_book=cls.book) 102 | cls.bio = Bio.objects.create(author=cls.author, best_book=cls.book) 103 | 104 | def test_reverse_is_correctly_set(self): 105 | with self.assertNumQueries(2): 106 | author = Author.objects.prefetch_related("bio").first() 107 | self.assertIs(author.bio.author, author) 108 | 109 | @use_persistent_prefetch_identity_map(pass_identity_map=True) 110 | def test_annotations(self, identity_map): 111 | # Test that annotations from Prefetch.queryset are applied even 112 | # if the prefetched object already exists in the identity map 113 | bio = identity_map[Bio.objects.first()] 114 | author = Author.objects.prefetch_related( 115 | Prefetch("bio", queryset=Bio.objects.annotate(double_id=2 * F("best_book_id"))) 116 | ).first() 117 | self.assertIs(author.bio, bio) 118 | self.assertEqual(author.bio.double_id, 2 * bio.best_book_id) 119 | 120 | @use_persistent_prefetch_identity_map(pass_identity_map=True) 121 | def test_select_related(self, identity_map): 122 | # Test that annotations from Prefetch.queryset are applied even 123 | # if the prefetched object already exists in the identity map 124 | bio = identity_map[Bio.objects.first()] 125 | author = Author.objects.prefetch_related( 126 | Prefetch("bio", queryset=Bio.objects.select_related("best_book")) 127 | ).first() 128 | 129 | # Check to make sure that the author is the one in the identity 130 | # map and that that it has the select_related object 131 | self.assertIs(author.bio, bio) 132 | with self.assertNumQueries(0): 133 | self.assertEqual(bio.best_book.id, bio.best_book_id) 134 | 135 | 136 | class ReverseManyToOneTests(EnableIdentityMapMixin, TestCase): 137 | @classmethod 138 | def setUpTestData(cls): 139 | cls.book = Book.objects.create(title="Poems") 140 | cls.author = Author.objects.create(name="Jane", first_book=cls.book) 141 | cls.bio = Bio.objects.create(author=cls.author, best_book=cls.book) 142 | 143 | def test_reverse_is_correctly_set(self): 144 | with self.assertNumQueries(2): 145 | book = Book.objects.prefetch_related("first_time_authors").first() 146 | self.assertIs(book.first_time_authors.all()[0].first_book, book) 147 | 148 | @use_persistent_prefetch_identity_map(pass_identity_map=True) 149 | def test_annotations(self, identity_map): 150 | # Test that annotations from Prefetch.queryset are applied even 151 | # if the prefetched object already exists in the identity map 152 | author = identity_map[Author.objects.first()] 153 | book = Book.objects.prefetch_related( 154 | Prefetch("first_time_authors", queryset=Author.objects.annotate(double_id=2 * F("id"))) 155 | ).first() 156 | self.assertIs(book.first_time_authors.all()[0], author) 157 | self.assertEqual(author.double_id, 2 * author.id) 158 | 159 | @use_persistent_prefetch_identity_map(pass_identity_map=True) 160 | def test_select_related(self, identity_map): 161 | # Test that annotations from Prefetch.queryset are applied even 162 | # if the prefetched object already exists in the identity map 163 | author = identity_map[Author.objects.first()] 164 | book = Book.objects.prefetch_related( 165 | Prefetch("first_time_authors", queryset=Author.objects.select_related("bio")) 166 | ).first() 167 | 168 | # Check to make sure that the author is the one in the identity 169 | # map and that that it has the select_related object 170 | self.assertIs(book.first_time_authors.all()[0], author) 171 | with self.assertNumQueries(0): 172 | self.assertIsNotNone(author.bio) 173 | 174 | 175 | class ManyToManyDescriptorTests(EnableIdentityMapMixin, TestCase): 176 | @classmethod 177 | def setUpTestData(cls): 178 | cls.book = Book.objects.create(title="Poems") 179 | cls.jane = Author.objects.create(name="Jane", first_book=cls.book) 180 | cls.charlotte = Author.objects.create(name="Charlotte", first_book=cls.book) 181 | FavoriteAuthors.objects.create(author=cls.jane, likes_author=cls.charlotte) 182 | 183 | @use_persistent_prefetch_identity_map(pass_identity_map=True) 184 | def test_annotations(self, identity_map): 185 | # Test that annotations from Prefetch.queryset are applied even 186 | # if the prefetched object already exists in the identity map 187 | favorite = identity_map[Author.objects.get(id=self.charlotte.id)] 188 | author = Author.objects.prefetch_related( 189 | Prefetch("favorite_authors", queryset=Author.objects.annotate(double_id=2 * F("id"))) 190 | ).get(id=self.jane.id) 191 | self.assertIs(author.favorite_authors.all()[0], favorite) 192 | self.assertEqual(favorite.double_id, 2 * favorite.id) 193 | 194 | @use_persistent_prefetch_identity_map(pass_identity_map=True) 195 | def test_select_related(self, identity_map): 196 | # Test that annotations from Prefetch.queryset are applied even 197 | # if the prefetched object already exists in the identity map 198 | favorite = identity_map[Author.objects.get(id=self.charlotte.id)] 199 | author = Author.objects.prefetch_related( 200 | Prefetch("favorite_authors", queryset=Author.objects.select_related("first_book")) 201 | ).get(id=self.jane.id) 202 | 203 | # Check to make sure that the author is the one in the identity 204 | # map and that that it has the select_related object 205 | self.assertIs(author.favorite_authors.all()[0], favorite) 206 | with self.assertNumQueries(0): 207 | self.assertIsNotNone(favorite.first_book) 208 | 209 | 210 | class GenericForeignKeyTests(EnableIdentityMapMixin, TestCase): 211 | @classmethod 212 | def setUpTestData(cls): 213 | cls.person = Person.objects.create(name="Jane") 214 | cls.bookmark = Bookmark.objects.create(url="https://www.rover.com") 215 | cls.bookmark2 = Bookmark.objects.create(url="https://www.rover.com/blog/") 216 | cls.tagged_item = TaggedItem.objects.create( 217 | content_object=cls.bookmark, created_by=cls.person, favorite=cls.person 218 | ) 219 | cls.tagged_item2 = TaggedItem.objects.create( 220 | content_object=cls.bookmark2, created_by=cls.person, favorite=cls.person 221 | ) 222 | 223 | @use_persistent_prefetch_identity_map(pass_identity_map=True) 224 | def test_identity_map_works_with_generic_foreign_keys(self, identity_map): 225 | bookmark = identity_map[Bookmark.objects.first()] 226 | with self.assertNumQueries(1): 227 | tagged_item = TaggedItem.objects.prefetch_related("content_object").first() 228 | self.assertIs(tagged_item.content_object, bookmark) 229 | 230 | @use_persistent_prefetch_identity_map(pass_identity_map=True) 231 | def test_identity_map_works_with_partially_fetched(self, identity_map): 232 | bookmark = identity_map[Bookmark.objects.first()] 233 | with self.assertNumQueries(2): 234 | tagged_items = list(TaggedItem.objects.prefetch_related("content_object")) 235 | self.assertIs(tagged_items[0].content_object, bookmark) 236 | 237 | 238 | class GenericRelationTests(EnableIdentityMapMixin, TestCase): 239 | @classmethod 240 | def setUpTestData(cls): 241 | cls.person = Person.objects.create(name="Jane") 242 | cls.bookmark = Bookmark.objects.create(url="https://www.rover.com") 243 | cls.tagged_item = TaggedItem.objects.create( 244 | content_object=cls.bookmark, created_by=cls.person, favorite=cls.person 245 | ) 246 | 247 | def test_reverse_is_correctly_set(self): 248 | with self.assertNumQueries(2): 249 | bookmark = Bookmark.objects.prefetch_related("tags__content_object").first() 250 | self.assertIs(bookmark.tags.all()[0].content_object, bookmark) 251 | 252 | @use_persistent_prefetch_identity_map(pass_identity_map=True) 253 | def test_annotations(self, identity_map): 254 | # Test that annotations from Prefetch.queryset are applied even 255 | # if the prefetched object already exists in the identity map 256 | tagged_item = identity_map[TaggedItem.objects.first()] 257 | bookmark = Bookmark.objects.prefetch_related( 258 | Prefetch("tags", queryset=TaggedItem.objects.annotate(double_id=2 * F("id"))) 259 | ).first() 260 | self.assertIs(bookmark.tags.all()[0], tagged_item) 261 | self.assertEqual(tagged_item.double_id, 2 * tagged_item.id) 262 | 263 | @use_persistent_prefetch_identity_map(pass_identity_map=True) 264 | def test_select_related(self, identity_map): 265 | tagged_item = identity_map[TaggedItem.objects.first()] 266 | bookmark = Bookmark.objects.prefetch_related( 267 | Prefetch("tags", queryset=TaggedItem.objects.select_related("favorite_ct")) 268 | ).first() 269 | self.assertIs(bookmark.tags.all()[0], tagged_item) 270 | with self.assertNumQueries(0): 271 | self.assertEqual(tagged_item.favorite_ct, ContentType.objects.get_for_model(Person)) 272 | 273 | 274 | class SelectRelatedTests(EnableIdentityMapMixin, TestCase): 275 | @classmethod 276 | def setUpTestData(cls): 277 | cls.book = Book.objects.create(title="Poems") 278 | cls.jane = Author.objects.create(name="Jane", first_book=cls.book) 279 | 280 | @use_persistent_prefetch_identity_map(pass_identity_map=True) 281 | def test_select_related_with_null_reverse_one_to_one(self, identity_map): 282 | jane = identity_map[self.jane] 283 | with self.assertNumQueries(2): 284 | book = Book.objects.prefetch_related( 285 | Prefetch("first_time_authors", queryset=Author.objects.select_related("bio")) 286 | ).first() 287 | 288 | bio_field = Author._meta.get_field("bio") 289 | with self.assertNumQueries(0): 290 | self.assertIs(book.first_time_authors.all()[0], jane) 291 | self.assertIsNone(bio_field.get_cached_value(jane)) 292 | 293 | @use_persistent_prefetch_identity_map(pass_identity_map=True) 294 | def test_select_related_with_annotation(self, identity_map): 295 | self.book.authors.add(self.jane) 296 | bio = Bio.objects.create(author=self.jane, best_book=self.book) 297 | bio = identity_map[bio] 298 | jane = identity_map[self.jane] 299 | with self.assertNumQueries(2): 300 | book = Book.objects.prefetch_related( 301 | Prefetch( 302 | "authors", 303 | queryset=Author.objects.select_related("bio").annotate(total_books=Count("books")).order_by("id"), 304 | ) 305 | ).first() 306 | 307 | with self.assertNumQueries(0): 308 | self.assertIs(book.authors.all()[0], jane) 309 | self.assertIs(jane.bio, bio) 310 | self.assertEqual(jane.total_books, 1) 311 | 312 | 313 | class PrefetchCompositionTests(EnableIdentityMapMixin, TestCase): 314 | @classmethod 315 | def setUpTestData(cls): 316 | cls.book = Book.objects.create(title="Poem") 317 | cls.author = Author.objects.create(name="Jane", first_book=cls.book) 318 | cls.book.authors.add(cls.author) 319 | 320 | def test_prefetching_works_in_cases_where_promotion_would_be_needed(self): 321 | queryset = Book.objects.prefetch_related( 322 | Prefetch( 323 | "first_time_authors", 324 | queryset=Author.objects.prefetch_related( 325 | Prefetch("first_book", queryset=Book.objects.prefetch_related("authors")) 326 | ), 327 | ) 328 | ) 329 | with self.assertNumQueries(3): 330 | (book,) = list(queryset) 331 | 332 | with self.assertNumQueries(0): 333 | self.assertIs(book, book.first_time_authors.all()[0].first_book) 334 | self.assertEqual(len(book.authors.all()), 1) 335 | -------------------------------------------------------------------------------- /tests/identity_map/misc_tests.py: -------------------------------------------------------------------------------- 1 | from django.db.models import Prefetch 2 | from django.test import TestCase 3 | from prefetch_related.models import Author 4 | from prefetch_related.models import Book 5 | from prefetch_related.models import DogWithToys 6 | from prefetch_related.models import ToyWithDogs 7 | from prefetch_related.tests import traverse_qs 8 | 9 | from django_prefetch_utils.identity_map import prefetch_related_objects 10 | from django_prefetch_utils.identity_map import use_prefetch_identity_map 11 | 12 | from .mixins import EnableIdentityMapMixin 13 | 14 | 15 | class UsePrefetchIdentityMapTests(TestCase): 16 | @classmethod 17 | def setUpTestData(cls): 18 | cls.book = Book.objects.create(title="Poems") 19 | cls.author = Author.objects.create(name="Jane", first_book=cls.book) 20 | cls.book.authors.add(cls.author) 21 | 22 | def test_use_prefetch_identity_map(self): 23 | with use_prefetch_identity_map(): 24 | author = Author.objects.prefetch_related("books", "first_book").first() 25 | with self.assertNumQueries(0): 26 | self.assertIs(author.books.all()[0], author.first_book) 27 | 28 | 29 | class PrefetchRelatedObjectsTests(TestCase): 30 | @classmethod 31 | def setUpTestData(cls): 32 | cls.book = Book.objects.create(title="Poems") 33 | cls.author = Author.objects.create(name="Jane", first_book=cls.book) 34 | cls.book.authors.add(cls.author) 35 | 36 | def setUp(self): 37 | super().setUp() 38 | self.book = Book.objects.first() 39 | 40 | def test_does_nothing_with_no_instances(self): 41 | with self.assertNumQueries(0): 42 | prefetch_related_objects([], "authors") 43 | 44 | def test_second_prefetch_with_queryset(self): 45 | with self.assertRaises(ValueError): 46 | prefetch_related_objects( 47 | [self.book], "authors", Prefetch("authors", queryset=Author.objects.prefetch_related("first_book")) 48 | ) 49 | 50 | def test_duplicate_prefetch_with_queryset(self): 51 | prefetch_related_objects([self.book], "authors") 52 | with self.assertNumQueries(0): 53 | prefetch_related_objects( 54 | [self.book], Prefetch("authors", queryset=Author.objects.prefetch_related("first_book")) 55 | ) 56 | self.assertIs(self.book.authors.all()[0].first_book, self.book) 57 | 58 | 59 | class RecursionProtectionTests(EnableIdentityMapMixin, TestCase): 60 | @classmethod 61 | def setUpTestData(cls): 62 | cls.toy1 = ToyWithDogs.objects.create(name="Ball") 63 | cls.toy2 = ToyWithDogs.objects.create(name="Stick") 64 | cls.dog1 = DogWithToys.objects.create(name="Spot") 65 | cls.dog2 = DogWithToys.objects.create(name="Fido") 66 | cls.toy1.dogs.add(cls.dog1) 67 | cls.toy2.dogs.add(cls.dog2) 68 | 69 | def test_recursion_protection(self): 70 | with self.assertNumQueries(3): 71 | dogs = set(DogWithToys.objects.all()) 72 | self.assertEqual(dogs, {self.dog1, self.dog2}) 73 | 74 | def test_traversing(self): 75 | with self.assertNumQueries(3): 76 | traverse_qs(DogWithToys.objects.all(), [["toys", "dogs", "toys", "dogs", "toys"]]) 77 | -------------------------------------------------------------------------------- /tests/identity_map/mixins.py: -------------------------------------------------------------------------------- 1 | from django_prefetch_utils import identity_map 2 | from django_prefetch_utils.selector import override_prefetch_related_objects 3 | 4 | 5 | class EnableIdentityMapMixin(object): 6 | def setUp(self): 7 | super(EnableIdentityMapMixin, self).setUp() 8 | cm = override_prefetch_related_objects(identity_map.prefetch_related_objects) 9 | cm.__enter__() 10 | self.addCleanup(lambda: cm.__exit__(None, None, None)) 11 | -------------------------------------------------------------------------------- /tests/identity_map/persistent_tests.py: -------------------------------------------------------------------------------- 1 | from django.db.models.query import QuerySet 2 | from django.test import TestCase 3 | from prefetch_related.models import Author 4 | from prefetch_related.models import Book 5 | 6 | from django_prefetch_utils.identity_map import get_default_prefetch_identity_map 7 | from django_prefetch_utils.identity_map.persistent import FetchAllDescriptor 8 | from django_prefetch_utils.identity_map.persistent import disable_fetch_all_descriptor 9 | from django_prefetch_utils.identity_map.persistent import enable_fetch_all_descriptor 10 | from django_prefetch_utils.identity_map.persistent import original_fetch_all 11 | from django_prefetch_utils.identity_map.persistent import use_persistent_prefetch_identity_map 12 | 13 | 14 | class PersistentPrefetchIdentityMapIntegrationTests(TestCase): 15 | @classmethod 16 | def setUpTestData(cls): 17 | cls.book = Book.objects.create(title="Poems") 18 | cls.author = Author.objects.create(name="Jane", first_book=cls.book) 19 | 20 | def setUp(self): 21 | super().setUp() 22 | cm = use_persistent_prefetch_identity_map() 23 | self.identity_map = cm.__enter__() 24 | self.addCleanup(lambda: cm.__exit__(None, None, None)) 25 | 26 | def test_get_is_fetched_from_identity_map(self): 27 | self.identity_map[self.author] 28 | self.assertIs(Author.objects.get(id=self.author.id), self.author) 29 | 30 | def test_first_is_fetched_from_identity_map(self): 31 | self.identity_map[self.author] 32 | self.assertIs(Author.objects.first(), self.author) 33 | 34 | def test_subsequent_fetches_use_correct_object(self): 35 | author = Author.objects.prefetch_related("first_book").first() 36 | self.identity_map[author] 37 | with self.assertNumQueries(1): 38 | self.assertIs(Author.objects.prefetch_related("first_book").first(), author) 39 | 40 | def test_custom_identity_map(self): 41 | identity_map = get_default_prefetch_identity_map() 42 | with use_persistent_prefetch_identity_map(identity_map) as in_use_map: 43 | self.assertIs(in_use_map, identity_map) 44 | 45 | @use_persistent_prefetch_identity_map(pass_identity_map=True) 46 | def test_use_as_method_decorator(self, identity_map): 47 | author = Author.objects.prefetch_related("first_book").first() 48 | identity_map[author] 49 | self.assertIs(Author.objects.first(), author) 50 | 51 | @use_persistent_prefetch_identity_map() 52 | def test_use_as_method_decorator_no_argument(self): 53 | author = Author.objects.prefetch_related("first_book").first() 54 | with self.assertNumQueries(1): 55 | author = Author.objects.first() 56 | self.assertEqual(author.first_book, self.book) 57 | 58 | def test_use_as_function_decorator(self): 59 | @use_persistent_prefetch_identity_map(pass_identity_map=True) 60 | def test_function(identity_map): 61 | author = Author.objects.prefetch_related("first_book").first() 62 | identity_map[author] 63 | self.assertIs(Author.objects.first(), author) 64 | 65 | test_function() 66 | 67 | 68 | class FetchAllDescriptorTests(TestCase): 69 | def setUp(self): 70 | super().setUp() 71 | enable_fetch_all_descriptor() 72 | self.addCleanup(disable_fetch_all_descriptor) 73 | 74 | def test_descriptor_is_installed_on_queryset(self): 75 | self.assertIsInstance(QuerySet._fetch_all, FetchAllDescriptor) 76 | 77 | def test_disable_descriptor(self): 78 | disable_fetch_all_descriptor() 79 | self.assertIs(QuerySet._fetch_all, original_fetch_all) 80 | -------------------------------------------------------------------------------- /tests/prefetch_related/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/roverdotcom/django-prefetch-utils/f901cb2159b3e95f44baf18812d5bbb7c52afd97/tests/prefetch_related/__init__.py -------------------------------------------------------------------------------- /tests/prefetch_related/models.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from django.contrib.contenttypes.fields import GenericForeignKey 4 | from django.contrib.contenttypes.fields import GenericRelation 5 | from django.contrib.contenttypes.models import ContentType 6 | from django.db import models 7 | from django.db.models.query import ModelIterable 8 | from django.db.models.query import QuerySet 9 | from django.utils.functional import cached_property 10 | 11 | 12 | class Author(models.Model): 13 | name = models.CharField(max_length=50, unique=True) 14 | first_book = models.ForeignKey("Book", models.CASCADE, related_name="first_time_authors") 15 | favorite_authors = models.ManyToManyField( 16 | "self", through="FavoriteAuthors", symmetrical=False, related_name="favors_me" 17 | ) 18 | 19 | class Meta: 20 | ordering = ["id"] 21 | 22 | def __str__(self): 23 | return self.name 24 | 25 | 26 | class AuthorWithAge(Author): 27 | author = models.OneToOneField(Author, models.CASCADE, parent_link=True) 28 | age = models.IntegerField() 29 | 30 | 31 | class FavoriteAuthors(models.Model): 32 | author = models.ForeignKey(Author, models.CASCADE, to_field="name", related_name="i_like") 33 | likes_author = models.ForeignKey(Author, models.CASCADE, to_field="name", related_name="likes_me") 34 | 35 | class Meta: 36 | ordering = ["id"] 37 | 38 | 39 | class AuthorAddress(models.Model): 40 | author = models.ForeignKey(Author, models.CASCADE, to_field="name", related_name="addresses") 41 | address = models.TextField() 42 | 43 | class Meta: 44 | ordering = ["id"] 45 | 46 | def __str__(self): 47 | return self.address 48 | 49 | 50 | class Book(models.Model): 51 | title = models.CharField(max_length=255) 52 | authors = models.ManyToManyField(Author, related_name="books") 53 | 54 | class Meta: 55 | ordering = ["id"] 56 | 57 | def __str__(self): 58 | return self.title 59 | 60 | 61 | class BookWithYear(Book): 62 | book = models.OneToOneField(Book, models.CASCADE, parent_link=True) 63 | published_year = models.IntegerField() 64 | aged_authors = models.ManyToManyField(AuthorWithAge, related_name="books_with_year") 65 | 66 | 67 | class Bio(models.Model): 68 | author = models.OneToOneField(Author, models.CASCADE, primary_key=True, to_field="name") 69 | best_book = models.ForeignKey(Book, on_delete=models.SET_NULL, null=True) 70 | books = models.ManyToManyField(Book, blank=True) 71 | 72 | 73 | class DirectBio(models.Model): 74 | author = models.OneToOneField(Author, models.CASCADE, primary_key=True, related_name="direct_bio") 75 | books = models.ManyToManyField(Book, blank=True) 76 | 77 | 78 | class YearlyBio(models.Model): 79 | author = models.ForeignKey(Author, models.CASCADE, primary_key=True, related_name="yearly_bios") 80 | year = models.IntegerField() 81 | books = models.ManyToManyField(Book, blank=True) 82 | 83 | 84 | class Reader(models.Model): 85 | name = models.CharField(max_length=50) 86 | books_read = models.ManyToManyField(Book, related_name="read_by") 87 | 88 | class Meta: 89 | ordering = ["id"] 90 | 91 | def __str__(self): 92 | return self.name 93 | 94 | 95 | class BookReview(models.Model): 96 | # Intentionally does not have a related name. 97 | book = models.ForeignKey(BookWithYear, models.CASCADE, null=True) 98 | notes = models.TextField(null=True, blank=True) 99 | 100 | 101 | # Models for default manager tests 102 | 103 | 104 | class Qualification(models.Model): 105 | name = models.CharField(max_length=10) 106 | 107 | class Meta: 108 | ordering = ["id"] 109 | 110 | 111 | class ModelIterableSubclass(ModelIterable): 112 | pass 113 | 114 | 115 | class TeacherQuerySet(QuerySet): 116 | def __init__(self, *args, **kwargs): 117 | super().__init__(*args, **kwargs) 118 | self._iterable_class = ModelIterableSubclass 119 | 120 | 121 | class TeacherManager(models.Manager): 122 | def get_queryset(self): 123 | return super().get_queryset().prefetch_related("qualifications") 124 | 125 | 126 | class Teacher(models.Model): 127 | name = models.CharField(max_length=50) 128 | qualifications = models.ManyToManyField(Qualification) 129 | 130 | objects = TeacherManager() 131 | objects_custom = TeacherQuerySet.as_manager() 132 | 133 | class Meta: 134 | ordering = ["id"] 135 | 136 | def __str__(self): 137 | return "%s (%s)" % (self.name, ", ".join(q.name for q in self.qualifications.all())) 138 | 139 | 140 | class Department(models.Model): 141 | name = models.CharField(max_length=50) 142 | teachers = models.ManyToManyField(Teacher) 143 | 144 | class Meta: 145 | ordering = ["id"] 146 | 147 | 148 | # GenericRelation/GenericForeignKey tests 149 | 150 | 151 | class TaggedItem(models.Model): 152 | tag = models.SlugField() 153 | content_type = models.ForeignKey(ContentType, models.CASCADE, related_name="taggeditem_set2") 154 | object_id = models.PositiveIntegerField() 155 | content_object = GenericForeignKey("content_type", "object_id") 156 | created_by_ct = models.ForeignKey(ContentType, models.SET_NULL, null=True, related_name="taggeditem_set3") 157 | created_by_fkey = models.PositiveIntegerField(null=True) 158 | created_by = GenericForeignKey("created_by_ct", "created_by_fkey") 159 | favorite_ct = models.ForeignKey(ContentType, models.SET_NULL, null=True, related_name="taggeditem_set4") 160 | favorite_fkey = models.CharField(max_length=64, null=True) 161 | favorite = GenericForeignKey("favorite_ct", "favorite_fkey") 162 | 163 | class Meta: 164 | ordering = ["id"] 165 | 166 | def __str__(self): 167 | return self.tag 168 | 169 | 170 | class Bookmark(models.Model): 171 | url = models.URLField() 172 | tags = GenericRelation(TaggedItem, related_query_name="bookmarks") 173 | favorite_tags = GenericRelation( 174 | TaggedItem, 175 | content_type_field="favorite_ct", 176 | object_id_field="favorite_fkey", 177 | related_query_name="favorite_bookmarks", 178 | ) 179 | 180 | class Meta: 181 | ordering = ["id"] 182 | 183 | 184 | class Comment(models.Model): 185 | comment = models.TextField() 186 | 187 | # Content-object field 188 | content_type = models.ForeignKey(ContentType, models.CASCADE) 189 | object_pk = models.TextField() 190 | content_object = GenericForeignKey(ct_field="content_type", fk_field="object_pk") 191 | 192 | class Meta: 193 | ordering = ["id"] 194 | 195 | 196 | # Models for lookup ordering tests 197 | 198 | 199 | class House(models.Model): 200 | name = models.CharField(max_length=50) 201 | address = models.CharField(max_length=255) 202 | owner = models.ForeignKey("Person", models.SET_NULL, null=True) 203 | main_room = models.OneToOneField("Room", models.SET_NULL, related_name="main_room_of", null=True) 204 | 205 | class Meta: 206 | ordering = ["id"] 207 | 208 | 209 | class Room(models.Model): 210 | name = models.CharField(max_length=50) 211 | house = models.ForeignKey(House, models.CASCADE, related_name="rooms") 212 | 213 | class Meta: 214 | ordering = ["id"] 215 | 216 | 217 | class Person(models.Model): 218 | name = models.CharField(max_length=50) 219 | houses = models.ManyToManyField(House, related_name="occupants") 220 | 221 | @property 222 | def primary_house(self): 223 | # Assume business logic forces every person to have at least one house. 224 | return sorted(self.houses.all(), key=lambda house: -house.rooms.count())[0] 225 | 226 | @property 227 | def all_houses(self): 228 | return list(self.houses.all()) 229 | 230 | @cached_property 231 | def cached_all_houses(self): 232 | return self.all_houses 233 | 234 | class Meta: 235 | ordering = ["id"] 236 | 237 | 238 | # Models for nullable FK tests 239 | 240 | 241 | class Employee(models.Model): 242 | name = models.CharField(max_length=50) 243 | boss = models.ForeignKey("self", models.SET_NULL, null=True, related_name="serfs") 244 | 245 | class Meta: 246 | ordering = ["id"] 247 | 248 | def __str__(self): 249 | return self.name 250 | 251 | 252 | # Ticket #19607 253 | 254 | 255 | class LessonEntry(models.Model): 256 | name1 = models.CharField(max_length=200) 257 | name2 = models.CharField(max_length=200) 258 | 259 | def __str__(self): 260 | return "%s %s" % (self.name1, self.name2) 261 | 262 | 263 | class WordEntry(models.Model): 264 | lesson_entry = models.ForeignKey(LessonEntry, models.CASCADE) 265 | name = models.CharField(max_length=200) 266 | 267 | def __str__(self): 268 | return "%s (%s)" % (self.name, self.id) 269 | 270 | 271 | # Ticket #21410: Regression when related_name="+" 272 | 273 | 274 | class Author2(models.Model): 275 | name = models.CharField(max_length=50, unique=True) 276 | first_book = models.ForeignKey("Book", models.CASCADE, related_name="first_time_authors+") 277 | favorite_books = models.ManyToManyField("Book", related_name="+") 278 | 279 | class Meta: 280 | ordering = ["id"] 281 | 282 | def __str__(self): 283 | return self.name 284 | 285 | 286 | # Models for many-to-many with UUID pk test: 287 | 288 | 289 | class Pet(models.Model): 290 | id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) 291 | name = models.CharField(max_length=20) 292 | people = models.ManyToManyField(Person, related_name="pets") 293 | 294 | 295 | class Flea(models.Model): 296 | id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) 297 | current_room = models.ForeignKey(Room, models.SET_NULL, related_name="fleas", null=True) 298 | pets_visited = models.ManyToManyField(Pet, related_name="fleas_hosted") 299 | people_visited = models.ManyToManyField(Person, related_name="fleas_hosted") 300 | 301 | 302 | # Models for testing recursion protection 303 | class DogWithToysManager(models.Manager): 304 | def get_queryset(self): 305 | qs = super().get_queryset() 306 | return qs.prefetch_related("toys") 307 | 308 | 309 | class DogWithToys(models.Model): 310 | name = models.CharField(max_length=50, unique=True) 311 | 312 | objects = DogWithToysManager() 313 | 314 | 315 | class ToyWithDogsManager(models.Manager): 316 | def get_queryset(self): 317 | qs = super().get_queryset() 318 | return qs.prefetch_related("dogs") 319 | 320 | 321 | class ToyWithDogs(models.Model): 322 | name = models.CharField(max_length=50, unique=True) 323 | dogs = models.ManyToManyField(DogWithToys, related_name="toys") 324 | 325 | objects = ToyWithDogsManager() 326 | 327 | 328 | class Dog(models.Model): 329 | name = models.CharField(max_length=50, unique=True) 330 | 331 | 332 | class Toy(models.Model): 333 | name = models.CharField(max_length=50, unique=True) 334 | dogs = models.ManyToManyField(Dog, related_name="toys") 335 | -------------------------------------------------------------------------------- /tests/prefetch_related/test_prefetch_related_objects.py: -------------------------------------------------------------------------------- 1 | from django.db.models import Prefetch 2 | from django.db.models import prefetch_related_objects 3 | from django.test import TestCase 4 | 5 | from .models import Author 6 | from .models import Book 7 | from .models import Reader 8 | 9 | 10 | class PrefetchRelatedObjectsTests(TestCase): 11 | """ 12 | Since prefetch_related_objects() is just the inner part of 13 | prefetch_related(), only do basic tests to ensure its API hasn't changed. 14 | """ 15 | 16 | @classmethod 17 | def setUpTestData(cls): 18 | cls.book1 = Book.objects.create(title="Poems") 19 | cls.book2 = Book.objects.create(title="Jane Eyre") 20 | cls.book3 = Book.objects.create(title="Wuthering Heights") 21 | cls.book4 = Book.objects.create(title="Sense and Sensibility") 22 | 23 | cls.author1 = Author.objects.create(name="Charlotte", first_book=cls.book1) 24 | cls.author2 = Author.objects.create(name="Anne", first_book=cls.book1) 25 | cls.author3 = Author.objects.create(name="Emily", first_book=cls.book1) 26 | cls.author4 = Author.objects.create(name="Jane", first_book=cls.book4) 27 | 28 | cls.book1.authors.add(cls.author1, cls.author2, cls.author3) 29 | cls.book2.authors.add(cls.author1) 30 | cls.book3.authors.add(cls.author3) 31 | cls.book4.authors.add(cls.author4) 32 | 33 | cls.reader1 = Reader.objects.create(name="Amy") 34 | cls.reader2 = Reader.objects.create(name="Belinda") 35 | 36 | cls.reader1.books_read.add(cls.book1, cls.book4) 37 | cls.reader2.books_read.add(cls.book2, cls.book4) 38 | 39 | def test_unknown(self): 40 | book1 = Book.objects.get(id=self.book1.id) 41 | with self.assertRaises(AttributeError): 42 | prefetch_related_objects([book1], "unknown_attribute") 43 | 44 | def test_m2m_forward(self): 45 | book1 = Book.objects.get(id=self.book1.id) 46 | with self.assertNumQueries(1): 47 | prefetch_related_objects([book1], "authors") 48 | 49 | with self.assertNumQueries(0): 50 | self.assertCountEqual(book1.authors.all(), [self.author1, self.author2, self.author3]) 51 | 52 | def test_m2m_reverse(self): 53 | author1 = Author.objects.get(id=self.author1.id) 54 | with self.assertNumQueries(1): 55 | prefetch_related_objects([author1], "books") 56 | 57 | with self.assertNumQueries(0): 58 | self.assertCountEqual(author1.books.all(), [self.book1, self.book2]) 59 | 60 | def test_foreignkey_forward(self): 61 | authors = list(Author.objects.all()) 62 | with self.assertNumQueries(1): 63 | prefetch_related_objects(authors, "first_book") 64 | 65 | with self.assertNumQueries(0): 66 | [author.first_book for author in authors] 67 | 68 | def test_foreignkey_reverse(self): 69 | books = list(Book.objects.all()) 70 | with self.assertNumQueries(1): 71 | prefetch_related_objects(books, "first_time_authors") 72 | 73 | with self.assertNumQueries(0): 74 | [list(book.first_time_authors.all()) for book in books] 75 | 76 | def test_m2m_then_m2m(self): 77 | """A m2m can be followed through another m2m.""" 78 | authors = list(Author.objects.all()) 79 | with self.assertNumQueries(2): 80 | prefetch_related_objects(authors, "books__read_by") 81 | 82 | with self.assertNumQueries(0): 83 | self.assertEqual( 84 | [[[str(r) for r in b.read_by.all()] for b in a.books.all()] for a in authors], 85 | [ 86 | [["Amy"], ["Belinda"]], # Charlotte - Poems, Jane Eyre 87 | [["Amy"]], # Anne - Poems 88 | [["Amy"], []], # Emily - Poems, Wuthering Heights 89 | [["Amy", "Belinda"]], # Jane - Sense and Sense 90 | ], 91 | ) 92 | 93 | def test_prefetch_object(self): 94 | book1 = Book.objects.get(id=self.book1.id) 95 | with self.assertNumQueries(1): 96 | prefetch_related_objects([book1], Prefetch("authors")) 97 | 98 | with self.assertNumQueries(0): 99 | self.assertCountEqual(book1.authors.all(), [self.author1, self.author2, self.author3]) 100 | 101 | def test_prefetch_object_to_attr(self): 102 | book1 = Book.objects.get(id=self.book1.id) 103 | with self.assertNumQueries(1): 104 | prefetch_related_objects([book1], Prefetch("authors", to_attr="the_authors")) 105 | 106 | with self.assertNumQueries(0): 107 | self.assertCountEqual(book1.the_authors, [self.author1, self.author2, self.author3]) 108 | 109 | def test_prefetch_queryset(self): 110 | book1 = Book.objects.get(id=self.book1.id) 111 | with self.assertNumQueries(1): 112 | prefetch_related_objects( 113 | [book1], Prefetch("authors", queryset=Author.objects.filter(id__in=[self.author1.id, self.author2.id])) 114 | ) 115 | 116 | with self.assertNumQueries(0): 117 | self.assertCountEqual(book1.authors.all(), [self.author1, self.author2]) 118 | -------------------------------------------------------------------------------- /tests/prefetch_related/test_uuid.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from .models import Flea 4 | from .models import House 5 | from .models import Person 6 | from .models import Pet 7 | from .models import Room 8 | 9 | 10 | class UUIDPrefetchRelated(TestCase): 11 | def test_prefetch_related_from_uuid_model(self): 12 | Pet.objects.create(name="Fifi").people.add( 13 | Person.objects.create(name="Ellen"), Person.objects.create(name="George") 14 | ) 15 | 16 | with self.assertNumQueries(2): 17 | pet = Pet.objects.prefetch_related("people").get(name="Fifi") 18 | with self.assertNumQueries(0): 19 | self.assertEqual(2, len(pet.people.all())) 20 | 21 | def test_prefetch_related_to_uuid_model(self): 22 | Person.objects.create(name="Bella").pets.add( 23 | Pet.objects.create(name="Socks"), Pet.objects.create(name="Coffee") 24 | ) 25 | 26 | with self.assertNumQueries(2): 27 | person = Person.objects.prefetch_related("pets").get(name="Bella") 28 | with self.assertNumQueries(0): 29 | self.assertEqual(2, len(person.pets.all())) 30 | 31 | def test_prefetch_related_from_uuid_model_to_uuid_model(self): 32 | fleas = [Flea.objects.create() for i in range(3)] 33 | Pet.objects.create(name="Fifi").fleas_hosted.add(*fleas) 34 | Pet.objects.create(name="Bobo").fleas_hosted.add(*fleas) 35 | 36 | with self.assertNumQueries(2): 37 | pet = Pet.objects.prefetch_related("fleas_hosted").get(name="Fifi") 38 | with self.assertNumQueries(0): 39 | self.assertEqual(3, len(pet.fleas_hosted.all())) 40 | 41 | with self.assertNumQueries(2): 42 | flea = Flea.objects.prefetch_related("pets_visited").get(pk=fleas[0].pk) 43 | with self.assertNumQueries(0): 44 | self.assertEqual(2, len(flea.pets_visited.all())) 45 | 46 | def test_prefetch_related_from_uuid_model_to_uuid_model_with_values_flat(self): 47 | pet = Pet.objects.create(name="Fifi") 48 | pet.people.add(Person.objects.create(name="Ellen"), Person.objects.create(name="George")) 49 | self.assertSequenceEqual(Pet.objects.prefetch_related("fleas_hosted").values_list("id", flat=True), [pet.id]) 50 | 51 | 52 | class UUIDPrefetchRelatedLookups(TestCase): 53 | @classmethod 54 | def setUpTestData(cls): 55 | house = House.objects.create(name="Redwood", address="Arcata") 56 | room = Room.objects.create(name="Racoon", house=house) 57 | fleas = [Flea.objects.create(current_room=room) for i in range(3)] 58 | pet = Pet.objects.create(name="Spooky") 59 | pet.fleas_hosted.add(*fleas) 60 | person = Person.objects.create(name="Bob") 61 | person.houses.add(house) 62 | person.pets.add(pet) 63 | person.fleas_hosted.add(*fleas) 64 | 65 | def test_from_uuid_pk_lookup_uuid_pk_integer_pk(self): 66 | # From uuid-pk model, prefetch .: 67 | with self.assertNumQueries(4): 68 | spooky = Pet.objects.prefetch_related("fleas_hosted__current_room__house").get(name="Spooky") 69 | with self.assertNumQueries(0): 70 | self.assertEqual("Racoon", spooky.fleas_hosted.all()[0].current_room.name) 71 | 72 | def test_from_uuid_pk_lookup_integer_pk2_uuid_pk2(self): 73 | # From uuid-pk model, prefetch ...: 74 | with self.assertNumQueries(5): 75 | spooky = Pet.objects.prefetch_related("people__houses__rooms__fleas").get(name="Spooky") 76 | with self.assertNumQueries(0): 77 | self.assertEqual(3, len(spooky.people.all()[0].houses.all()[0].rooms.all()[0].fleas.all())) 78 | 79 | def test_from_integer_pk_lookup_uuid_pk_integer_pk(self): 80 | # From integer-pk model, prefetch .: 81 | with self.assertNumQueries(3): 82 | racoon = Room.objects.prefetch_related("fleas__people_visited").get(name="Racoon") 83 | with self.assertNumQueries(0): 84 | self.assertEqual("Bob", racoon.fleas.all()[0].people_visited.all()[0].name) 85 | 86 | def test_from_integer_pk_lookup_integer_pk_uuid_pk(self): 87 | # From integer-pk model, prefetch .: 88 | with self.assertNumQueries(3): 89 | redwood = House.objects.prefetch_related("rooms__fleas").get(name="Redwood") 90 | with self.assertNumQueries(0): 91 | self.assertEqual(3, len(redwood.rooms.all()[0].fleas.all())) 92 | 93 | def test_from_integer_pk_lookup_integer_pk_uuid_pk_uuid_pk(self): 94 | # From integer-pk model, prefetch ..: 95 | with self.assertNumQueries(4): 96 | redwood = House.objects.prefetch_related("rooms__fleas__pets_visited").get(name="Redwood") 97 | with self.assertNumQueries(0): 98 | self.assertEqual("Spooky", redwood.rooms.all()[0].fleas.all()[0].pets_visited.all()[0].name) 99 | -------------------------------------------------------------------------------- /tests/pyenv_markers.py: -------------------------------------------------------------------------------- 1 | import django 2 | import pytest 3 | 4 | requires_django_2_2 = pytest.mark.skipif(django.VERSION < (2, 2), reason="at least Django 2.2 required") 5 | 6 | requires_django_2_1 = pytest.mark.skipif(django.VERSION < (2, 1), reason="at least Django 2.1 required") 7 | 8 | requires_django_2_0 = pytest.mark.skipif(django.VERSION < (2, 0), reason="at least Django 2.0 required") 9 | -------------------------------------------------------------------------------- /tests/selector_tests.py: -------------------------------------------------------------------------------- 1 | import django.db.models.query 2 | from django.test import TestCase 3 | 4 | from django_prefetch_utils.selector import _prefetch_related_objects_selector 5 | from django_prefetch_utils.selector import disable_prefetch_related_objects_selector 6 | from django_prefetch_utils.selector import enable_prefetch_related_objects_selector 7 | from django_prefetch_utils.selector import get_prefetch_related_objects 8 | from django_prefetch_utils.selector import original_prefetch_related_objects 9 | from django_prefetch_utils.selector import override_prefetch_related_objects 10 | from django_prefetch_utils.selector import remove_default_prefetch_related_objects 11 | from django_prefetch_utils.selector import set_default_prefetch_related_objects 12 | from django_prefetch_utils.selector import use_original_prefetch_related_objects 13 | 14 | 15 | def mock_implementation(*args, **kwargs): 16 | pass # pragma: no cover 17 | 18 | 19 | class SelectorTests(TestCase): 20 | def setUp(self): 21 | super().setUp() 22 | self.addCleanup(enable_prefetch_related_objects_selector) 23 | self.addCleanup(remove_default_prefetch_related_objects) 24 | 25 | def test_selector_is_enabled_when_app_is_in_installed_apps(self): 26 | self.assertIs(django.db.models.query.prefetch_related_objects, _prefetch_related_objects_selector) 27 | 28 | def test_disable_selector(self): 29 | disable_prefetch_related_objects_selector() 30 | self.assertIs(django.db.models.query.prefetch_related_objects, original_prefetch_related_objects) 31 | 32 | def test_reenabledisable_selector(self): 33 | disable_prefetch_related_objects_selector() 34 | enable_prefetch_related_objects_selector() 35 | self.assertIs(django.db.models.query.prefetch_related_objects, _prefetch_related_objects_selector) 36 | 37 | def test_get_prefetch_related_objects_returns_original_by_default(self): 38 | self.assertIs(get_prefetch_related_objects(), original_prefetch_related_objects) 39 | 40 | def test_get_prefetch_related_objects_with_default(self): 41 | set_default_prefetch_related_objects(mock_implementation) 42 | self.assertIs(get_prefetch_related_objects(), mock_implementation) 43 | 44 | def test_override_prefetch_related_objects(self): 45 | with override_prefetch_related_objects(mock_implementation): 46 | self.assertIs(get_prefetch_related_objects(), mock_implementation) 47 | self.assertIs(get_prefetch_related_objects(), original_prefetch_related_objects) 48 | 49 | @override_prefetch_related_objects(mock_implementation) 50 | def test_override_prefetch_related_objects_as_decorator(self): 51 | self.assertIs(get_prefetch_related_objects(), mock_implementation) 52 | 53 | def test_use_original_prefetch_related_objects(self): 54 | set_default_prefetch_related_objects(mock_implementation) 55 | with use_original_prefetch_related_objects(): 56 | self.assertIs(get_prefetch_related_objects(), original_prefetch_related_objects) 57 | self.assertIs(get_prefetch_related_objects(), mock_implementation) 58 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | USE_TZ = False 2 | 3 | SECRET_KEY = "roverdotcom" 4 | INSTALLED_APPS = [ 5 | "django.contrib.contenttypes", 6 | "django.contrib.auth", 7 | "django.contrib.sites", 8 | "django.contrib.sessions", 9 | "django.contrib.messages", 10 | "django.contrib.admin.apps.SimpleAdminConfig", 11 | "django.contrib.staticfiles", 12 | "django_prefetch_utils", 13 | "prefetch_related", 14 | "foreign_object", 15 | "descriptors_tests", 16 | ] 17 | ROOT_URLCONF = [] 18 | 19 | MIGRATION_MODULES = { 20 | # This lets us skip creating migrations for the test models as many of 21 | # them depend on one of the following contrib applications. 22 | "auth": None, 23 | "contenttypes": None, 24 | "sessions": None, 25 | } 26 | 27 | 28 | DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3"}, "other": {"ENGINE": "django.db.backends.sqlite3"}} 29 | 30 | # Use a fast hasher to speed up tests. 31 | PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"] 32 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | clean, 4 | check, 5 | docs, 6 | 3.10-2.2-cover, 7 | 3.10-3.2-cover, 8 | 3.10-4.0-cover, 9 | 3.7-2.2-cover, 10 | 3.7-3.2-cover, 11 | 3.8-2.2-cover, 12 | 3.8-3.2-cover, 13 | 3.8-4.0-cover, 14 | 3.9-2.2-cover, 15 | 3.9-3.2-cover, 16 | 3.9-4.0-cover, 17 | report 18 | 19 | [gh-actions] 20 | python = 21 | 3.7: 3.7 22 | 3.8: 3.8 23 | 3.9: 3.9 24 | 3.10: 3.10,docs,clean,check 25 | 26 | [testenv] 27 | basepython = 28 | {docs,bootstrap,clean,check,report,codecov}: {env:TOXPYTHON:python3} 29 | setenv = 30 | PYTHONPATH={toxinidir}/tests 31 | PYTHONUNBUFFERED=yes 32 | passenv = 33 | * 34 | deps = 35 | pytest 36 | pytest-django 37 | pytest-travis-fold 38 | future 39 | commands = 40 | {posargs:pytest -vv --ignore=src} 41 | 42 | [testenv:bootstrap] 43 | deps = 44 | jinja2 45 | matrix 46 | skip_install = true 47 | commands = 48 | python ci/bootstrap.py 49 | 50 | [testenv:check] 51 | deps = 52 | docutils 53 | check-manifest 54 | flake8 55 | readme-renderer 56 | pygments 57 | isort 58 | twine 59 | skip_install = true 60 | commands = 61 | python setup.py sdist 62 | twine check dist/*.tar.gz 63 | check-manifest {toxinidir} 64 | flake8 src tests setup.py 65 | isort --verbose --check-only --diff src tests setup.py 66 | 67 | 68 | [testenv:docs] 69 | deps = 70 | -r{toxinidir}/docs/requirements.txt 71 | commands = 72 | sphinx-build {posargs:-E} -b html docs dist/docs 73 | sphinx-build -b linkcheck docs dist/docs 74 | 75 | 76 | [testenv:codecov] 77 | deps = 78 | codecov 79 | skip_install = true 80 | commands = 81 | coverage xml --ignore-errors 82 | codecov [] 83 | 84 | [testenv:report] 85 | deps = coverage 86 | skip_install = true 87 | commands = 88 | coverage report 89 | coverage html 90 | 91 | [testenv:clean] 92 | commands = coverage erase 93 | skip_install = true 94 | deps = coverage 95 | 96 | [testenv:3.10-2.2-cover] 97 | basepython = {env:TOXPYTHON:python3.10} 98 | setenv = 99 | {[testenv]setenv} 100 | usedevelop = true 101 | commands = 102 | {posargs:pytest --cov --cov-report=term-missing -vv} 103 | deps = 104 | {[testenv]deps} 105 | pytest-cov 106 | Django~=2.2.0 107 | 108 | [testenv:3.10-3.2-cover] 109 | basepython = {env:TOXPYTHON:python3.10} 110 | setenv = 111 | {[testenv]setenv} 112 | usedevelop = true 113 | commands = 114 | {posargs:pytest --cov --cov-report=term-missing -vv} 115 | deps = 116 | {[testenv]deps} 117 | pytest-cov 118 | Django~=3.2.0 119 | 120 | [testenv:3.10-4.0-cover] 121 | basepython = {env:TOXPYTHON:python3.10} 122 | setenv = 123 | {[testenv]setenv} 124 | usedevelop = true 125 | commands = 126 | {posargs:pytest --cov --cov-report=term-missing -vv} 127 | deps = 128 | {[testenv]deps} 129 | pytest-cov 130 | Django~=4.0.0 131 | 132 | [testenv:3.7-2.2-cover] 133 | basepython = {env:TOXPYTHON:python3.7} 134 | setenv = 135 | {[testenv]setenv} 136 | usedevelop = true 137 | commands = 138 | {posargs:pytest --cov --cov-report=term-missing -vv} 139 | deps = 140 | {[testenv]deps} 141 | pytest-cov 142 | Django~=2.2.0 143 | 144 | [testenv:3.7-3.2-cover] 145 | basepython = {env:TOXPYTHON:python3.7} 146 | setenv = 147 | {[testenv]setenv} 148 | usedevelop = true 149 | commands = 150 | {posargs:pytest --cov --cov-report=term-missing -vv} 151 | deps = 152 | {[testenv]deps} 153 | pytest-cov 154 | Django~=3.2.0 155 | 156 | [testenv:3.8-2.2-cover] 157 | basepython = {env:TOXPYTHON:python3.8} 158 | setenv = 159 | {[testenv]setenv} 160 | usedevelop = true 161 | commands = 162 | {posargs:pytest --cov --cov-report=term-missing -vv} 163 | deps = 164 | {[testenv]deps} 165 | pytest-cov 166 | Django~=2.2.0 167 | 168 | [testenv:3.8-3.2-cover] 169 | basepython = {env:TOXPYTHON:python3.8} 170 | setenv = 171 | {[testenv]setenv} 172 | usedevelop = true 173 | commands = 174 | {posargs:pytest --cov --cov-report=term-missing -vv} 175 | deps = 176 | {[testenv]deps} 177 | pytest-cov 178 | Django~=3.2.0 179 | 180 | [testenv:3.8-4.0-cover] 181 | basepython = {env:TOXPYTHON:python3.8} 182 | setenv = 183 | {[testenv]setenv} 184 | usedevelop = true 185 | commands = 186 | {posargs:pytest --cov --cov-report=term-missing -vv} 187 | deps = 188 | {[testenv]deps} 189 | pytest-cov 190 | Django~=4.0.0 191 | 192 | [testenv:3.9-2.2-cover] 193 | basepython = {env:TOXPYTHON:python3.9} 194 | setenv = 195 | {[testenv]setenv} 196 | usedevelop = true 197 | commands = 198 | {posargs:pytest --cov --cov-report=term-missing -vv} 199 | deps = 200 | {[testenv]deps} 201 | pytest-cov 202 | Django~=2.2.0 203 | 204 | [testenv:3.9-3.2-cover] 205 | basepython = {env:TOXPYTHON:python3.9} 206 | setenv = 207 | {[testenv]setenv} 208 | usedevelop = true 209 | commands = 210 | {posargs:pytest --cov --cov-report=term-missing -vv} 211 | deps = 212 | {[testenv]deps} 213 | pytest-cov 214 | Django~=3.2.0 215 | 216 | [testenv:3.9-4.0-cover] 217 | basepython = {env:TOXPYTHON:python3.9} 218 | setenv = 219 | {[testenv]setenv} 220 | usedevelop = true 221 | commands = 222 | {posargs:pytest --cov --cov-report=term-missing -vv} 223 | deps = 224 | {[testenv]deps} 225 | pytest-cov 226 | Django~=4.0.0 227 | --------------------------------------------------------------------------------