├── .github └── workflows │ ├── pypi.yml │ └── tests.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── django_cte ├── __init__.py ├── _deprecated.py ├── cte.py ├── jitmixin.py ├── join.py ├── meta.py ├── query.py └── raw.py ├── docs ├── _config.yml └── index.md ├── pyproject.toml ├── tests ├── __init__.py ├── django_setup.py ├── models.py ├── settings.py ├── test_combinators.py ├── test_cte.py ├── test_django.py ├── test_manager.py ├── test_raw.py ├── test_recursive.py └── test_v1 │ ├── __init__.py │ ├── models.py │ ├── test_combinators.py │ ├── test_cte.py │ ├── test_django.py │ ├── test_manager.py │ ├── test_raw.py │ └── test_recursive.py └── uv.lock /.github/workflows/pypi.yml: -------------------------------------------------------------------------------- 1 | name: Publish Python distribution to PyPI and TestPyPI 2 | # Source: 3 | # https://packaging.python.org/en/latest/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/ 4 | on: 5 | push: 6 | branches: 7 | - main 8 | tags: 9 | - 'v*' 10 | workflow_dispatch: 11 | jobs: 12 | build: 13 | name: Build distribution package 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: astral-sh/setup-uv@v6 18 | with: 19 | version: '>=0.7' 20 | - name: Check for version match in git tag and django_cte.__version__ 21 | if: startsWith(github.ref, 'refs/tags/v') 22 | run: uvx pyverno check django_cte/__init__.py "${{ github.ref }}" 23 | - name: Add untagged version suffix 24 | if: ${{ ! startsWith(github.ref, 'refs/tags/v') }} 25 | run: uvx pyverno update django_cte/__init__.py 26 | - name: Build a binary wheel and a source tarball 27 | run: uv build 28 | - name: Store the distribution packages 29 | uses: actions/upload-artifact@v4 30 | with: 31 | name: python-package-distributions 32 | path: dist/ 33 | pypi-publish: 34 | name: Upload release to PyPI 35 | needs: [build] 36 | runs-on: ubuntu-latest 37 | environment: 38 | name: pypi 39 | url: https://pypi.org/p/django-cte 40 | permissions: 41 | id-token: write 42 | steps: 43 | - name: Download all the dists 44 | uses: actions/download-artifact@v4 45 | with: 46 | name: python-package-distributions 47 | path: dist/ 48 | - name: Publish package distributions to PyPI 49 | uses: pypa/gh-action-pypi-publish@release/v1 50 | pypi-test-publish: 51 | name: Upload release to test PyPI 52 | needs: [build] 53 | runs-on: ubuntu-latest 54 | environment: 55 | name: testpypi 56 | url: https://test.pypi.org/p/django-cte 57 | permissions: 58 | id-token: write 59 | steps: 60 | - name: Download all the dists 61 | uses: actions/download-artifact@v4 62 | with: 63 | name: python-package-distributions 64 | path: dist/ 65 | - name: Publish package distributions to PyPI 66 | uses: pypa/gh-action-pypi-publish@release/v1 67 | with: 68 | repository-url: https://test.pypi.org/legacy/ 69 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: django-cte tests 2 | on: 3 | push: 4 | branches: [main] 5 | pull_request: 6 | branches: [main] 7 | workflow_dispatch: 8 | 9 | jobs: 10 | configure: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Read Python versions from pyproject.toml 15 | id: read-python-versions 16 | # produces output like: python_versions=[ "3.9", "3.10", "3.11", "3.12" ] 17 | run: >- 18 | echo "python_versions=$( 19 | grep -oP '(?<=Language :: Python :: )\d\.\d+' pyproject.toml 20 | | jq --raw-input . 21 | | jq --slurp . 22 | | tr '\n' ' ' 23 | )" >> $GITHUB_OUTPUT 24 | - name: Read Django versions from pyproject.toml 25 | id: read-django-versions 26 | # django_versions=[ "Django~=4.2.0", "Django~=5.1.0", "Django~=5.2.0" ] 27 | run: >- 28 | echo "django_versions=$( 29 | grep -oP '(?<=Framework :: Django :: )\d+\.\d+' pyproject.toml 30 | | sed -E 's/(.+)/Django~=\1.0/' 31 | | jq --raw-input . 32 | | jq --slurp . 33 | | tr '\n' ' ' 34 | )" >> $GITHUB_OUTPUT 35 | outputs: 36 | python_versions: ${{ steps.read-python-versions.outputs.python_versions }} 37 | django_versions: ${{ steps.read-django-versions.outputs.django_versions }} 38 | 39 | tests: 40 | needs: [configure] 41 | runs-on: ubuntu-latest 42 | strategy: 43 | fail-fast: false 44 | matrix: 45 | python: ${{ fromJSON(needs.configure.outputs.python_versions) }} 46 | django: ${{ fromJSON(needs.configure.outputs.django_versions) }} 47 | exclude: 48 | - {python: '3.9', django: 'Django~=5.1.0'} 49 | - {python: '3.9', django: 'Django~=5.2.0'} 50 | env: 51 | allowed_python_failure: '3.14' 52 | services: 53 | postgres: 54 | image: postgres:latest 55 | env: 56 | POSTGRES_DB: postgres 57 | POSTGRES_PASSWORD: postgres 58 | POSTGRES_USER: postgres 59 | ports: 60 | - 5432:5432 61 | options: >- 62 | --health-cmd pg_isready 63 | --health-interval 10s 64 | --health-timeout 5s 65 | --health-retries 5 66 | steps: 67 | - uses: actions/checkout@v3 68 | - uses: astral-sh/setup-uv@v6 69 | with: 70 | version: '>=0.7' 71 | python-version: ${{ matrix.python }} 72 | - name: Setup 73 | run: | 74 | uv sync --locked --no-install-package=django 75 | uv pip install "${{ matrix.django }}" 76 | - name: Run tests on PostgreSQL 77 | env: 78 | DB_SETTINGS: >- 79 | { 80 | "ENGINE":"django.db.backends.postgresql_psycopg2", 81 | "NAME":"django_cte", 82 | "USER":"postgres", 83 | "PASSWORD":"postgres", 84 | "HOST":"localhost", 85 | "PORT":"5432" 86 | } 87 | run: .venv/bin/pytest -v 88 | continue-on-error: ${{ matrix.python == env.allowed_python_failure }} 89 | - name: Run tests on SQLite 90 | run: .venv/bin/pytest -v 91 | continue-on-error: ${{ matrix.python == env.allowed_python_failure }} 92 | - name: Check style 93 | run: .venv/bin/ruff check 94 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # mypy 101 | .mypy_cache/ 102 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Django CTE change log 2 | 3 | ## 1.3.3 - 2024-06-07 4 | 5 | - Handle empty result sets in CTEs ([#92](https://github.com/dimagi/django-cte/pull/92)). 6 | - Fix `.explain()` in Django >= 4.0 ([#91](https://github.com/dimagi/django-cte/pull/91)). 7 | - Fixed bug in deferred loading ([#90](https://github.com/dimagi/django-cte/pull/90)). 8 | 9 | ## 1.3.2 - 2023-11-20 10 | 11 | - Work around changes in Django 4.2 that broke CTE queries due to internally 12 | generated column aliases in the query compiler. The workaround is not always 13 | effective. Some queries will produce mal-formed SQL. For example, CTE queries 14 | with window functions. 15 | 16 | ## 1.3.1 - 2023-06-13 17 | 18 | - Fix: `.update()` did not work when using CTE manager or when accessing nested 19 | tables. 20 | 21 | ## 1.3.0 - 2023-05-24 22 | 23 | - Add support for Materialized CTEs. 24 | - Fix: add EXPLAIN clause in correct position when using `.explain()` method. 25 | 26 | ## v1.2.1 - 2022-07-07 27 | 28 | - Fix compatibility with non-CTE models. 29 | 30 | ## v1.2.0 - 2022-03-30 31 | 32 | - Add support for Django 3.1, 3.2 and 4.0. 33 | - Quote the CTE table name if needed. 34 | - Resolve `OuterRef` in CTE `Subquery`. 35 | - Fix default `CTEManager` so it can use `from_queryset` corectly. 36 | - Fix for Django 3.0.5+. 37 | 38 | ## v1.1.5 - 2020-02-07 39 | 40 | - Django 3 compatibility. Thank you @tim-schilling and @ryanhiebert! 41 | 42 | ## v1.1.4 - 2018-07-30 43 | 44 | - Python 3 compatibility. 45 | 46 | ## v1.1.3 - 2018-06-19 47 | 48 | - Fix CTE alias bug. 49 | 50 | ## v1.1.2 - 2018-05-22 51 | 52 | - Use `_default_manager` instead of `objects`. 53 | 54 | ## v1.1.1 - 2018-04-13 55 | 56 | - Fix recursive CTE pickling. Note: this is currently [broken on Django 57 | master](https://github.com/django/django/pull/9134#pullrequestreview-112057277). 58 | 59 | ## v1.1.0 - 2018-04-09 60 | 61 | - `With.queryset()` now uses the CTE model's manager to create a new `QuerySet`, 62 | which makes it easier to work with custom `QuerySet` classes. 63 | 64 | ## v1.0.0 - 2018-04-04 65 | 66 | - BACKWARD INCOMPATIBLE CHANGE: `With.queryset()` no longer accepts a `model` 67 | argument. 68 | - Improve `With.queryset()` to select directly from the CTE rather than 69 | joining to anoter QuerySet. 70 | - Refactor `With.join()` to use real JOIN clause. 71 | 72 | ## v0.1.4 - 2018-03-21 73 | 74 | - Fix related field attname masking CTE column. 75 | 76 | ## v0.1.3 - 2018-03-15 77 | 78 | - Add `django_cte.raw.raw_cte_sql` for constructing CTEs with raw SQL. 79 | 80 | ## v0.1.2 - 2018-02-21 81 | 82 | - Improve error on bad recursive reference. 83 | - Add more tests. 84 | - Add change log. 85 | - Improve README. 86 | - PEP-8 style fixes. 87 | 88 | ## v0.1.1 - 2018-02-21 89 | 90 | - Fix readme formatting on PyPI. 91 | 92 | ## v0.1 - 2018-02-21 93 | 94 | - Initial implementation. 95 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018, Dimagi Inc., and individual contributors. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | * Redistributions of source code must retain the above copyright 7 | notice, this list of conditions and the following disclaimer. 8 | * Redistributions in binary form must reproduce the above copyright 9 | notice, this list of conditions and the following disclaimer in the 10 | documentation and/or other materials provided with the distribution. 11 | * Neither the name Dimagi, nor the names of its contributors, may be used 12 | to endorse or promote products derived from this software without 13 | specific prior written permission. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 16 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL DIMAGI INC. BE LIABLE FOR ANY 19 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 20 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 21 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 22 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 23 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 24 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Common Table Expressions with Django 2 | 3 | [![Build Status](https://github.com/dimagi/django-cte/actions/workflows/tests.yml/badge.svg)](https://github.com/dimagi/django-cte/actions/workflows/tests.yml) 4 | [![PyPI version](https://badge.fury.io/py/django-cte.svg)](https://badge.fury.io/py/django-cte) 5 | 6 | ## Installation 7 | ``` 8 | pip install django-cte 9 | ``` 10 | 11 | 12 | ## Documentation 13 | 14 | The [django-cte documentation](https://dimagi.github.io/django-cte/) shows how 15 | to use Common Table Expressions with the Django ORM. 16 | 17 | 18 | ## Running tests 19 | 20 | ``` 21 | cd django-cte 22 | uv sync 23 | 24 | pytest 25 | ruff check 26 | 27 | # To run tests against postgres 28 | psql -U username -h localhost -p 5432 -c 'create database django_cte;' 29 | export PG_DB_SETTINGS='{ 30 | "ENGINE":"django.db.backends.postgresql_psycopg2", 31 | "NAME":"django_cte", 32 | "USER":"username", 33 | "PASSWORD":"password", 34 | "HOST":"localhost", 35 | "PORT":"5432"}' 36 | 37 | # WARNING pytest will delete the test_django_cte database if it exists! 38 | DB_SETTINGS="$PG_DB_SETTINGS" pytest 39 | ``` 40 | 41 | All feature and bug contributions are expected to be covered by tests. 42 | 43 | 44 | ## Publishing a new verison to PyPI 45 | 46 | Push a new tag to Github using the format vX.Y.Z where X.Y.Z matches the version 47 | in [`__init__.py`](django_cte/__init__.py). 48 | 49 | A new version is published to https://test.pypi.org/p/django-cte on every 50 | push to the *main* branch. 51 | 52 | Publishing is automated with [Github Actions](.github/workflows/pypi.yml). 53 | -------------------------------------------------------------------------------- /django_cte/__init__.py: -------------------------------------------------------------------------------- 1 | from .cte import CTE, with_cte, CTEManager, CTEQuerySet, With # noqa 2 | 3 | __version__ = "2.0.0" 4 | __all__ = ["CTE", "with_cte"] 5 | -------------------------------------------------------------------------------- /django_cte/_deprecated.py: -------------------------------------------------------------------------------- 1 | try: 2 | from warnings import deprecated 3 | except ImportError: 4 | from warnings import warn 5 | 6 | # Copied from Python 3.13, lightly modified for Python 3.9 compatibility. 7 | # Can be removed when the oldest supported Python version is 3.13. 8 | class deprecated: 9 | """Indicate that a class, function or overload is deprecated. 10 | 11 | When this decorator is applied to an object, the type checker 12 | will generate a diagnostic on usage of the deprecated object. 13 | 14 | Usage: 15 | 16 | @deprecated("Use B instead") 17 | class A: 18 | pass 19 | 20 | @deprecated("Use g instead") 21 | def f(): 22 | pass 23 | 24 | @overload 25 | @deprecated("int support is deprecated") 26 | def g(x: int) -> int: ... 27 | @overload 28 | def g(x: str) -> int: ... 29 | 30 | The warning specified by *category* will be emitted at runtime 31 | on use of deprecated objects. For functions, that happens on calls; 32 | for classes, on instantiation and on creation of subclasses. 33 | If the *category* is ``None``, no warning is emitted at runtime. 34 | The *stacklevel* determines where the 35 | warning is emitted. If it is ``1`` (the default), the warning 36 | is emitted at the direct caller of the deprecated object; if it 37 | is higher, it is emitted further up the stack. 38 | Static type checker behavior is not affected by the *category* 39 | and *stacklevel* arguments. 40 | 41 | The deprecation message passed to the decorator is saved in the 42 | ``__deprecated__`` attribute on the decorated object. 43 | If applied to an overload, the decorator 44 | must be after the ``@overload`` decorator for the attribute to 45 | exist on the overload as returned by ``get_overloads()``. 46 | 47 | See PEP 702 for details. 48 | 49 | """ 50 | def __init__( 51 | self, 52 | message: str, 53 | /, 54 | *, 55 | category=DeprecationWarning, 56 | stacklevel=1, 57 | ): 58 | if not isinstance(message, str): 59 | raise TypeError( 60 | f"Expected an object of type str for 'message', not {type(message).__name__!r}" 61 | ) 62 | self.message = message 63 | self.category = category 64 | self.stacklevel = stacklevel 65 | 66 | def __call__(self, arg, /): 67 | # Make sure the inner functions created below don't 68 | # retain a reference to self. 69 | msg = self.message 70 | category = self.category 71 | stacklevel = self.stacklevel 72 | if category is None: 73 | arg.__deprecated__ = msg 74 | return arg 75 | elif isinstance(arg, type): 76 | import functools 77 | from types import MethodType 78 | 79 | original_new = arg.__new__ 80 | 81 | @functools.wraps(original_new) 82 | def __new__(cls, /, *args, **kwargs): 83 | if cls is arg: 84 | warn(msg, category=category, stacklevel=stacklevel + 1) 85 | if original_new is not object.__new__: 86 | return original_new(cls, *args, **kwargs) 87 | # Mirrors a similar check in object.__new__. 88 | elif cls.__init__ is object.__init__ and (args or kwargs): 89 | raise TypeError(f"{cls.__name__}() takes no arguments") 90 | else: 91 | return original_new(cls) 92 | 93 | arg.__new__ = staticmethod(__new__) 94 | 95 | original_init_subclass = arg.__init_subclass__ 96 | # We need slightly different behavior if __init_subclass__ 97 | # is a bound method (likely if it was implemented in Python) 98 | if isinstance(original_init_subclass, MethodType): 99 | original_init_subclass = original_init_subclass.__func__ 100 | 101 | @functools.wraps(original_init_subclass) 102 | def __init_subclass__(*args, **kwargs): 103 | warn(msg, category=category, stacklevel=stacklevel + 1) 104 | return original_init_subclass(*args, **kwargs) 105 | 106 | arg.__init_subclass__ = classmethod(__init_subclass__) 107 | # Or otherwise, which likely means it's a builtin such as 108 | # object's implementation of __init_subclass__. 109 | else: 110 | @functools.wraps(original_init_subclass) 111 | def __init_subclass__(*args, **kwargs): 112 | warn(msg, category=category, stacklevel=stacklevel + 1) 113 | return original_init_subclass(*args, **kwargs) 114 | 115 | arg.__init_subclass__ = __init_subclass__ 116 | 117 | arg.__deprecated__ = __new__.__deprecated__ = msg 118 | __init_subclass__.__deprecated__ = msg 119 | return arg 120 | elif callable(arg): 121 | import functools 122 | import inspect 123 | 124 | @functools.wraps(arg) 125 | def wrapper(*args, **kwargs): 126 | warn(msg, category=category, stacklevel=stacklevel + 1) 127 | return arg(*args, **kwargs) 128 | 129 | if inspect.iscoroutinefunction(arg): 130 | wrapper = inspect.markcoroutinefunction(wrapper) 131 | 132 | arg.__deprecated__ = wrapper.__deprecated__ = msg 133 | return wrapper 134 | else: 135 | raise TypeError( 136 | "@deprecated decorator with non-None category must be applied to " 137 | f"a class or callable, not {arg!r}" 138 | ) 139 | -------------------------------------------------------------------------------- /django_cte/cte.py: -------------------------------------------------------------------------------- 1 | from copy import copy 2 | 3 | from django.db.models import Manager, sql 4 | from django.db.models.expressions import Ref 5 | from django.db.models.query import Q, QuerySet, ValuesIterable 6 | from django.db.models.sql.datastructures import BaseTable 7 | 8 | from .jitmixin import jit_mixin 9 | from .join import QJoin, INNER 10 | from .meta import CTEColumnRef, CTEColumns 11 | from .query import CTEQuery 12 | from ._deprecated import deprecated 13 | 14 | __all__ = ["CTE", "with_cte"] 15 | 16 | 17 | def with_cte(*ctes, select): 18 | """Add Common Table Expression(s) (CTEs) to a model or queryset 19 | 20 | :param *ctes: One or more CTE objects. 21 | :param select: A model class, queryset, or CTE to use as the base 22 | query to which CTEs are attached. 23 | :returns: A queryset with the given CTE added to it. 24 | """ 25 | if isinstance(select, CTE): 26 | select = select.queryset() 27 | elif not isinstance(select, QuerySet): 28 | select = select._default_manager.all() 29 | jit_mixin(select.query, CTEQuery) 30 | select.query._with_ctes += ctes 31 | return select 32 | 33 | 34 | class CTE: 35 | """Common Table Expression 36 | 37 | :param queryset: A queryset to use as the body of the CTE. 38 | :param name: Optional name parameter for the CTE (default: "cte"). 39 | This must be a unique name that does not conflict with other 40 | entities (tables, views, functions, other CTE(s), etc.) referenced 41 | in the given query as well any query to which this CTE will 42 | eventually be added. 43 | :param materialized: Optional parameter (default: False) which enforce 44 | using of MATERIALIZED statement for supporting databases. 45 | """ 46 | 47 | def __init__(self, queryset, name="cte", materialized=False): 48 | self.query = None if queryset is None else queryset.query 49 | self.name = name 50 | self.col = CTEColumns(self) 51 | self.materialized = materialized 52 | 53 | def __getstate__(self): 54 | return (self.query, self.name, self.materialized) 55 | 56 | def __setstate__(self, state): 57 | self.query, self.name, self.materialized = state 58 | self.col = CTEColumns(self) 59 | 60 | def __repr__(self): 61 | return "".format(self.name) 62 | 63 | @classmethod 64 | def recursive(cls, make_cte_queryset, name="cte", materialized=False): 65 | """Recursive Common Table Expression 66 | 67 | :param make_cte_queryset: Function taking a single argument (a 68 | not-yet-fully-constructed cte object) and returning a `QuerySet` 69 | object. The returned `QuerySet` normally consists of an initial 70 | statement unioned with a recursive statement. 71 | :param name: See `name` parameter of `__init__`. 72 | :param materialized: See `materialized` parameter of `__init__`. 73 | :returns: The fully constructed recursive cte object. 74 | """ 75 | cte = cls(None, name, materialized) 76 | cte.query = make_cte_queryset(cte).query 77 | return cte 78 | 79 | def join(self, model_or_queryset, *filter_q, **filter_kw): 80 | """Join this CTE to the given model or queryset 81 | 82 | This CTE will be referenced by the returned queryset, but the 83 | 84 | corresponding `WITH ...` statement will not be prepended to the 85 | queryset's SQL output; use `with_cte(cte, select=cte.join(...))` 86 | to achieve that outcome. 87 | 88 | :param model_or_queryset: Model class or queryset to which the 89 | CTE should be joined. 90 | :param *filter_q: Join condition Q expressions (optional). 91 | :param **filter_kw: Join conditions. All LHS fields (kwarg keys) 92 | are assumed to reference `model_or_queryset` fields. Use 93 | `cte.col.name` on the RHS to recursively reference CTE query 94 | columns. For example: `cte.join(Book, id=cte.col.id)` 95 | :returns: A queryset with the given model or queryset joined to 96 | this CTE. 97 | """ 98 | if isinstance(model_or_queryset, QuerySet): 99 | queryset = model_or_queryset.all() 100 | else: 101 | queryset = model_or_queryset._default_manager.all() 102 | join_type = filter_kw.pop("_join_type", INNER) 103 | query = queryset.query 104 | 105 | # based on Query.add_q: add necessary joins to query, but no filter 106 | q_object = Q(*filter_q, **filter_kw) 107 | map = query.alias_map 108 | existing_inner = set(a for a in map if map[a].join_type == INNER) 109 | on_clause, _ = query._add_q(q_object, query.used_aliases) 110 | query.demote_joins(existing_inner) 111 | 112 | parent = query.get_initial_alias() 113 | query.join(QJoin(parent, self.name, self.name, on_clause, join_type)) 114 | return queryset 115 | 116 | def queryset(self): 117 | """Get a queryset selecting from this CTE 118 | 119 | This CTE will be referenced by the returned queryset, but the 120 | corresponding `WITH ...` statement will not be prepended to the 121 | queryset's SQL output; use `with_cte(cte, select=cte)` to do 122 | that. 123 | 124 | :returns: A queryset. 125 | """ 126 | cte_query = self.query 127 | qs = cte_query.model._default_manager.get_queryset() 128 | 129 | query = jit_mixin(sql.Query(cte_query.model), CTEQuery) 130 | query.join(BaseTable(self.name, None)) 131 | query.default_cols = cte_query.default_cols 132 | query.deferred_loading = cte_query.deferred_loading 133 | if cte_query.values_select: 134 | query.set_values(cte_query.values_select) 135 | qs._iterable_class = ValuesIterable 136 | for alias in getattr(cte_query, "selected", None) or (): 137 | if alias not in cte_query.annotations: 138 | col = Ref(alias, cte_query.resolve_ref(alias)) 139 | query.add_annotation(col, alias) 140 | if cte_query.annotations: 141 | for alias, value in cte_query.annotations.items(): 142 | col = CTEColumnRef(alias, self.name, value.output_field) 143 | query.add_annotation(col, alias) 144 | query.annotation_select_mask = cte_query.annotation_select_mask 145 | 146 | qs.query = query 147 | return qs 148 | 149 | def _resolve_ref(self, name): 150 | selected = getattr(self.query, "selected", None) 151 | if selected and name in selected and name not in self.query.annotations: 152 | return Ref(name, self.query.resolve_ref(name)) 153 | return self.query.resolve_ref(name) 154 | 155 | def resolve_expression(self, *args, **kw): 156 | if self.query is None: 157 | raise ValueError("Cannot resolve recursive CTE without a query.") 158 | clone = copy(self) 159 | clone.query = clone.query.resolve_expression(*args, **kw) 160 | return clone 161 | 162 | 163 | @deprecated("Use `django_cte.CTE` instead.") 164 | class With(CTE): 165 | 166 | @staticmethod 167 | @deprecated("Use `django_cte.CTE.recursive` instead.") 168 | def recursive(*args, **kw): 169 | return CTE.recursive(*args, **kw) 170 | 171 | 172 | @deprecated("CTEQuerySet is deprecated. " 173 | "CTEs can now be applied to any queryset using `with_cte()`") 174 | class CTEQuerySet(QuerySet): 175 | """QuerySet with support for Common Table Expressions""" 176 | 177 | def __init__(self, model=None, query=None, using=None, hints=None): 178 | # Only create an instance of a Query if this is the first invocation in 179 | # a query chain. 180 | super(CTEQuerySet, self).__init__(model, query, using, hints) 181 | jit_mixin(self.query, CTEQuery) 182 | 183 | @deprecated("Use `django_cte.with_cte(cte, select=...)` instead.") 184 | def with_cte(self, cte): 185 | qs = self._clone() 186 | qs.query._with_ctes += cte, 187 | return qs 188 | 189 | def as_manager(cls): 190 | # Address the circular dependency between 191 | # `CTEQuerySet` and `CTEManager`. 192 | manager = CTEManager.from_queryset(cls)() 193 | manager._built_with_as_manager = True 194 | return manager 195 | as_manager.queryset_only = True 196 | as_manager = classmethod(as_manager) 197 | 198 | 199 | @deprecated("CTEMAnager is deprecated. " 200 | "CTEs can now be applied to any queryset using `with_cte()`") 201 | class CTEManager(Manager.from_queryset(CTEQuerySet)): 202 | """Manager for models that perform CTE queries""" 203 | 204 | @classmethod 205 | def from_queryset(cls, queryset_class, class_name=None): 206 | if not issubclass(queryset_class, CTEQuerySet): 207 | raise TypeError( 208 | "models with CTE support need to use a CTEQuerySet") 209 | return super(CTEManager, cls).from_queryset( 210 | queryset_class, class_name=class_name) 211 | -------------------------------------------------------------------------------- /django_cte/jitmixin.py: -------------------------------------------------------------------------------- 1 | def jit_mixin(obj, mixin): 2 | """Apply mixin to object and return the object""" 3 | if not isinstance(obj, mixin): 4 | obj.__class__ = jit_mixin_type(obj.__class__, mixin) 5 | return obj 6 | 7 | 8 | def jit_mixin_type(base, *mixins): 9 | assert not issubclass(base, mixins), (base, mixins) 10 | mixed = _mixin_cache.get((base, mixins)) 11 | if mixed is None: 12 | prefix = "".join(m._jit_mixin_prefix for m in mixins) 13 | name = f"{prefix}{base.__name__}" 14 | mixed = _mixin_cache[(base, mixins)] = type(name, (*mixins, base), { 15 | "_jit_mixin_base": getattr(base, "_jit_mixin_base", base), 16 | "_jit_mixins": mixins + getattr(base, "_jit_mixins", ()), 17 | }) 18 | return mixed 19 | 20 | 21 | _mixin_cache = {} 22 | 23 | 24 | class JITMixin: 25 | 26 | def __reduce__(self): 27 | # make JITMixin classes pickleable 28 | return (jit_mixin_type, (self._jit_mixin_base, *self._jit_mixins)) 29 | -------------------------------------------------------------------------------- /django_cte/join.py: -------------------------------------------------------------------------------- 1 | from django.db.models.sql.constants import INNER 2 | 3 | 4 | class QJoin: 5 | """Join clause with join condition from Q object clause 6 | 7 | :param parent_alias: Alias of parent table. 8 | :param table_name: Name of joined table. 9 | :param table_alias: Alias of joined table. 10 | :param on_clause: Query `where_class` instance represenging the ON clause. 11 | :param join_type: Join type (INNER or LOUTER). 12 | """ 13 | 14 | filtered_relation = None 15 | 16 | def __init__(self, parent_alias, table_name, table_alias, 17 | on_clause, join_type=INNER, nullable=None): 18 | self.parent_alias = parent_alias 19 | self.table_name = table_name 20 | self.table_alias = table_alias 21 | self.on_clause = on_clause 22 | self.join_type = join_type # LOUTER or INNER 23 | self.nullable = join_type != INNER if nullable is None else nullable 24 | 25 | @property 26 | def identity(self): 27 | return ( 28 | self.__class__, 29 | self.table_name, 30 | self.parent_alias, 31 | self.join_type, 32 | self.on_clause, 33 | ) 34 | 35 | def __hash__(self): 36 | return hash(self.identity) 37 | 38 | def __eq__(self, other): 39 | if not isinstance(other, QJoin): 40 | return NotImplemented 41 | return self.identity == other.identity 42 | 43 | def equals(self, other): 44 | return self.identity == other.identity 45 | 46 | def as_sql(self, compiler, connection): 47 | """Generate join clause SQL""" 48 | on_clause_sql, params = self.on_clause.as_sql(compiler, connection) 49 | if self.table_alias == self.table_name: 50 | alias = '' 51 | else: 52 | alias = ' %s' % self.table_alias 53 | qn = compiler.quote_name_unless_alias 54 | sql = '%s %s%s ON %s' % ( 55 | self.join_type, 56 | qn(self.table_name), 57 | alias, 58 | on_clause_sql 59 | ) 60 | return sql, params 61 | 62 | def relabeled_clone(self, change_map): 63 | return self.__class__( 64 | parent_alias=change_map.get(self.parent_alias, self.parent_alias), 65 | table_name=self.table_name, 66 | table_alias=change_map.get(self.table_alias, self.table_alias), 67 | on_clause=self.on_clause.relabeled_clone(change_map), 68 | join_type=self.join_type, 69 | nullable=self.nullable, 70 | ) 71 | 72 | class join_field: 73 | # `Join.join_field` is used internally by `Join` as well as in 74 | # `QuerySet.resolve_expression()`: 75 | # 76 | # isinstance(table, Join) 77 | # and table.join_field.related_model._meta.db_table != alias 78 | # 79 | # Currently that does not apply here since `QJoin` is not an 80 | # instance of `Join`, although maybe it should? Maybe this 81 | # should have `related_model._meta.db_table` return 82 | # `.table_name` or `.table_alias`? 83 | # 84 | # `PathInfo.join_field` is another similarly named attribute in 85 | # Django that has a much more complicated interface, but luckily 86 | # seems unrelated to `Join.join_field`. 87 | 88 | class related_model: 89 | class _meta: 90 | # for QuerySet.set_group_by(allow_aliases=True) 91 | local_concrete_fields = () 92 | -------------------------------------------------------------------------------- /django_cte/meta.py: -------------------------------------------------------------------------------- 1 | import weakref 2 | 3 | from django.db.models.expressions import Col, Expression 4 | 5 | 6 | class CTEColumns: 7 | 8 | def __init__(self, cte): 9 | self._cte = weakref.ref(cte) 10 | 11 | def __getattr__(self, name): 12 | return CTEColumn(self._cte(), name) 13 | 14 | 15 | class CTEColumn(Expression): 16 | 17 | def __init__(self, cte, name, output_field=None): 18 | self._cte = cte 19 | self.table_alias = cte.name 20 | self.name = self.alias = name 21 | self._output_field = output_field 22 | 23 | def __repr__(self): 24 | return "<{} {}.{}>".format( 25 | self.__class__.__name__, 26 | self._cte.name, 27 | self.name, 28 | ) 29 | 30 | @property 31 | def _ref(self): 32 | if self._cte.query is None: 33 | raise ValueError( 34 | "cannot resolve '{cte}.{name}' in recursive CTE setup. " 35 | "Hint: use ExpressionWrapper({cte}.col.{name}, " 36 | "output_field=...)".format(cte=self._cte.name, name=self.name) 37 | ) 38 | ref = self._cte._resolve_ref(self.name) 39 | if ref is self or self in ref.get_source_expressions(): 40 | raise ValueError("Circular reference: {} = {}".format(self, ref)) 41 | return ref 42 | 43 | @property 44 | def target(self): 45 | return self._ref.target 46 | 47 | @property 48 | def output_field(self): 49 | # required to fix error caused by django commit 50 | # 9d519d3dc4e5bd1d9ff3806b44624c3e487d61c1 51 | if self._cte.query is None: 52 | raise AttributeError 53 | 54 | if self._output_field is not None: 55 | return self._output_field 56 | return self._ref.output_field 57 | 58 | def as_sql(self, compiler, connection): 59 | qn = compiler.quote_name_unless_alias 60 | ref = self._ref 61 | if isinstance(ref, Col) and self.name == "pk": 62 | column = ref.target.column 63 | else: 64 | column = self.name 65 | return "%s.%s" % (qn(self.table_alias), qn(column)), [] 66 | 67 | def relabeled_clone(self, relabels): 68 | if self.table_alias is not None and self.table_alias in relabels: 69 | clone = self.copy() 70 | clone.table_alias = relabels[self.table_alias] 71 | return clone 72 | return self 73 | 74 | 75 | class CTEColumnRef(Expression): 76 | 77 | def __init__(self, name, cte_name, output_field): 78 | self.name = name 79 | self.cte_name = cte_name 80 | self.output_field = output_field 81 | self._alias = None 82 | 83 | def resolve_expression(self, query=None, allow_joins=True, reuse=None, 84 | summarize=False, for_save=False): 85 | if query: 86 | clone = self.copy() 87 | clone._alias = self._alias or query.table_map.get( 88 | self.cte_name, [self.cte_name])[0] 89 | return clone 90 | return super().resolve_expression( 91 | query, allow_joins, reuse, summarize, for_save) 92 | 93 | def relabeled_clone(self, change_map): 94 | if ( 95 | self.cte_name not in change_map 96 | and self._alias not in change_map 97 | ): 98 | return super().relabeled_clone(change_map) 99 | 100 | clone = self.copy() 101 | if self.cte_name in change_map: 102 | clone._alias = change_map[self.cte_name] 103 | 104 | if self._alias in change_map: 105 | clone._alias = change_map[self._alias] 106 | return clone 107 | 108 | def as_sql(self, compiler, connection): 109 | qn = compiler.quote_name_unless_alias 110 | table = self._alias or compiler.query.table_map.get( 111 | self.cte_name, [self.cte_name])[0] 112 | return "%s.%s" % (qn(table), qn(self.name)), [] 113 | -------------------------------------------------------------------------------- /django_cte/query.py: -------------------------------------------------------------------------------- 1 | import django 2 | from django.core.exceptions import EmptyResultSet 3 | from django.db.models.sql.constants import LOUTER 4 | 5 | from .jitmixin import JITMixin, jit_mixin 6 | from .join import QJoin 7 | 8 | # NOTE: it is currently not possible to execute delete queries that 9 | # reference CTEs without patching `QuerySet.delete` (Django method) 10 | # to call `self.query.chain(sql.DeleteQuery)` instead of 11 | # `sql.DeleteQuery(self.model)` 12 | 13 | 14 | class CTEQuery(JITMixin): 15 | """A Query mixin that processes SQL compilation through a CTE compiler""" 16 | _jit_mixin_prefix = "CTE" 17 | _with_ctes = () 18 | 19 | @property 20 | def combined_queries(self): 21 | return self.__dict__.get("combined_queries", ()) 22 | 23 | @combined_queries.setter 24 | def combined_queries(self, queries): 25 | ctes = [] 26 | seen = {cte.name: cte for cte in self._with_ctes} 27 | for query in queries: 28 | for cte in getattr(query, "_with_ctes", ()): 29 | if seen.get(cte.name) is cte: 30 | continue 31 | if cte.name in seen: 32 | raise ValueError( 33 | f"Found two or more CTEs named '{cte.name}'. " 34 | "Hint: assign a unique name to each CTE." 35 | ) 36 | ctes.append(cte) 37 | seen[cte.name] = cte 38 | 39 | if seen: 40 | def without_ctes(query): 41 | if getattr(query, "_with_ctes", None): 42 | query = query.clone() 43 | del query._with_ctes 44 | return query 45 | 46 | self._with_ctes += tuple(ctes) 47 | queries = tuple(without_ctes(q) for q in queries) 48 | self.__dict__["combined_queries"] = queries 49 | 50 | def resolve_expression(self, *args, **kwargs): 51 | clone = super().resolve_expression(*args, **kwargs) 52 | clone._with_ctes = tuple( 53 | cte.resolve_expression(*args, **kwargs) 54 | for cte in clone._with_ctes 55 | ) 56 | return clone 57 | 58 | def get_compiler(self, *args, **kwargs): 59 | return jit_mixin(super().get_compiler(*args, **kwargs), CTECompiler) 60 | 61 | def chain(self, klass=None): 62 | clone = jit_mixin(super().chain(klass), CTEQuery) 63 | clone._with_ctes = self._with_ctes 64 | return clone 65 | 66 | 67 | def generate_cte_sql(connection, query, as_sql): 68 | if not query._with_ctes: 69 | return as_sql() 70 | 71 | ctes = [] 72 | params = [] 73 | for cte in query._with_ctes: 74 | if django.VERSION > (4, 2): 75 | _ignore_with_col_aliases(cte.query) 76 | 77 | alias = query.alias_map.get(cte.name) 78 | should_elide_empty = ( 79 | not isinstance(alias, QJoin) or alias.join_type != LOUTER 80 | ) 81 | 82 | compiler = cte.query.get_compiler( 83 | connection=connection, elide_empty=should_elide_empty 84 | ) 85 | 86 | qn = compiler.quote_name_unless_alias 87 | try: 88 | cte_sql, cte_params = compiler.as_sql() 89 | except EmptyResultSet: 90 | # If the CTE raises an EmptyResultSet the SqlCompiler still 91 | # needs to know the information about this base compiler 92 | # like, col_count and klass_info. 93 | as_sql() 94 | raise 95 | template = get_cte_query_template(cte) 96 | ctes.append(template.format(name=qn(cte.name), query=cte_sql)) 97 | params.extend(cte_params) 98 | 99 | explain_attribute = "explain_info" 100 | explain_info = getattr(query, explain_attribute, None) 101 | explain_format = getattr(explain_info, "format", None) 102 | explain_options = getattr(explain_info, "options", {}) 103 | 104 | explain_query_or_info = getattr(query, explain_attribute, None) 105 | sql = [] 106 | if explain_query_or_info: 107 | sql.append( 108 | connection.ops.explain_query_prefix( 109 | explain_format, 110 | **explain_options 111 | ) 112 | ) 113 | # this needs to get set to None so that the base as_sql() doesn't 114 | # insert the EXPLAIN statement where it would end up between the 115 | # WITH ... clause and the final SELECT 116 | setattr(query, explain_attribute, None) 117 | 118 | if ctes: 119 | # Always use WITH RECURSIVE 120 | # https://www.postgresql.org/message-id/13122.1339829536%40sss.pgh.pa.us 121 | sql.extend(["WITH RECURSIVE", ", ".join(ctes)]) 122 | base_sql, base_params = as_sql() 123 | 124 | if explain_query_or_info: 125 | setattr(query, explain_attribute, explain_query_or_info) 126 | 127 | sql.append(base_sql) 128 | params.extend(base_params) 129 | return " ".join(sql), tuple(params) 130 | 131 | 132 | def get_cte_query_template(cte): 133 | if cte.materialized: 134 | return "{name} AS MATERIALIZED ({query})" 135 | return "{name} AS ({query})" 136 | 137 | 138 | def _ignore_with_col_aliases(cte_query): 139 | if getattr(cte_query, "combined_queries", None): 140 | cte_query.combined_queries = tuple( 141 | jit_mixin(q, NoAliasQuery) for q in cte_query.combined_queries 142 | ) 143 | 144 | 145 | class CTECompiler(JITMixin): 146 | """Mixin for django.db.models.sql.compiler.SQLCompiler""" 147 | _jit_mixin_prefix = "CTE" 148 | 149 | def as_sql(self, *args, **kwargs): 150 | def _as_sql(): 151 | return super(CTECompiler, self).as_sql(*args, **kwargs) 152 | return generate_cte_sql(self.connection, self.query, _as_sql) 153 | 154 | 155 | class NoAliasQuery(JITMixin): 156 | """Mixin for django.db.models.sql.compiler.Query""" 157 | _jit_mixin_prefix = "NoAlias" 158 | 159 | def get_compiler(self, *args, **kwargs): 160 | return jit_mixin(super().get_compiler(*args, **kwargs), NoAliasCompiler) 161 | 162 | 163 | class NoAliasCompiler(JITMixin): 164 | """Mixin for django.db.models.sql.compiler.SQLCompiler""" 165 | _jit_mixin_prefix = "NoAlias" 166 | 167 | def get_select(self, *, with_col_aliases=False, **kw): 168 | return super().get_select(**kw) 169 | -------------------------------------------------------------------------------- /django_cte/raw.py: -------------------------------------------------------------------------------- 1 | def raw_cte_sql(sql, params, refs): 2 | """Raw CTE SQL 3 | 4 | :param sql: SQL query (string). 5 | :param params: List of bind parameters. 6 | :param refs: Dict of output fields: `{"name": }`. 7 | :returns: Object that can be passed to `With`. 8 | """ 9 | 10 | class raw_cte_ref: 11 | def __init__(self, output_field): 12 | self.output_field = output_field 13 | 14 | def get_source_expressions(self): 15 | return [] 16 | 17 | class raw_cte_compiler: 18 | 19 | def __init__(self, connection): 20 | self.connection = connection 21 | 22 | def as_sql(self): 23 | return sql, params 24 | 25 | def quote_name_unless_alias(self, name): 26 | return self.connection.ops.quote_name(name) 27 | 28 | class raw_cte_queryset: 29 | class query: 30 | @staticmethod 31 | def get_compiler(connection, *, elide_empty=None): 32 | return raw_cte_compiler(connection) 33 | 34 | @staticmethod 35 | def resolve_ref(name): 36 | return raw_cte_ref(refs[name]) 37 | 38 | return raw_cte_queryset 39 | -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | title: django-cte 2 | author: Dimagi 3 | markdown: kramdown 4 | kramdown: 5 | toc_levels: 2..3 -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Common Table Expressions with Django 2 | 3 | * Table of contents (this line will not be displayed). 4 | {:toc} 5 | 6 | A Common Table Expression acts like a temporary table or view that exists only 7 | for the duration of the query it is attached to. django-cte allows common table 8 | expressions to be attached to normal Django ORM queries. 9 | 10 | 11 | ## Simple Common Table Expressions 12 | 13 | See [Appendix A](#appendix-a-model-definitions-used-in-sample-code) for model 14 | definitions used in sample code. 15 | 16 | Simple CTEs are constructed using `CTE(...)`. A CTE is added to a queryset using 17 | `with_cte(cte, select=queryset)`, which adds the `WITH` expression before the 18 | main `SELECT` query. A CTE can be joined to a model or other `QuerySet` using 19 | its `.join(...)` method, which creates a new queryset with a `JOIN` and 20 | `ON` condition. 21 | 22 | ```py 23 | from django_cte import CTE, with_cte 24 | 25 | cte = CTE( 26 | Order.objects 27 | .values("region_id") 28 | .annotate(total=Sum("amount")) 29 | ) 30 | 31 | orders = with_cte( 32 | # WITH cte ... 33 | cte, 34 | 35 | # SELECT ... FROM orders INNER JOIN cte ON orders.region_id = cte.region_id 36 | select=cte.join(Order, region=cte.col.region_id) 37 | 38 | # Annotate each Order with a "region_total" 39 | .annotate(region_total=cte.col.total) 40 | ) 41 | 42 | print(orders.query) # print SQL 43 | ``` 44 | 45 | The `orders` SQL, after formatting for readability, would look something like 46 | this: 47 | 48 | ```sql 49 | WITH RECURSIVE "cte" AS ( 50 | SELECT 51 | "orders"."region_id", 52 | SUM("orders"."amount") AS "total" 53 | FROM "orders" 54 | GROUP BY "orders"."region_id" 55 | ) 56 | SELECT 57 | "orders"."id", 58 | "orders"."region_id", 59 | "orders"."amount", 60 | "cte"."total" AS "region_total" 61 | FROM "orders" 62 | INNER JOIN "cte" ON "orders"."region_id" = "cte"."region_id" 63 | ``` 64 | 65 | The `orders` query is a queryset containing annotated `Order` objects, just as 66 | you would get from a query like `Order.objects.annotate(region_total=...)`. Each 67 | `Order` object will be annotated with a `region_total` attribute, which is 68 | populated with the value of the corresponding total from the joined CTE query. 69 | 70 | You may have noticed the CTE in this query uses `WITH RECURSIVE` even though 71 | this is not a [Recursive Common Table Expression](#recursive-common-table-expressions). 72 | The `RECURSIVE` keyword is always used, even for non-recursive CTEs. On 73 | databases such as PostgreSQL and SQLite this has no effect other than allowing 74 | recursive CTEs to be included in the WITH block. 75 | 76 | 77 | ## Recursive Common Table Expressions 78 | 79 | Recursive CTE queries allow fundamentally new types of queries that are 80 | not otherwise possible. 81 | 82 | Recursive CTEs are constructed using `CTE.recursive()`, which takes as its 83 | first argument a function that constructs and returns a recursive query. 84 | Recursive queries have two elements: first a non-recursive query element, and 85 | second a recursive query element. The second is typically attached to the first 86 | using `QuerySet.union()`. 87 | 88 | ```py 89 | def make_regions_cte(cte): 90 | # non-recursive: get root nodes 91 | return Region.objects.filter( 92 | parent__isnull=True 93 | ).values( 94 | "name", 95 | path=F("name"), 96 | depth=Value(0, output_field=IntegerField()), 97 | ).union( 98 | # recursive union: get descendants 99 | cte.join(Region, parent=cte.col.name).values( 100 | "name", 101 | path=Concat( 102 | cte.col.path, Value(" / "), F("name"), 103 | output_field=TextField(), 104 | ), 105 | depth=cte.col.depth + Value(1, output_field=IntegerField()), 106 | ), 107 | all=True, 108 | ) 109 | 110 | cte = CTE.recursive(make_regions_cte) 111 | 112 | regions = with_cte( 113 | cte, 114 | select=cte.join(Region, name=cte.col.name) 115 | .annotate( 116 | path=cte.col.path, 117 | depth=cte.col.depth, 118 | ) 119 | .filter(depth=2) 120 | .order_by("path") 121 | ) 122 | ``` 123 | 124 | `Region` objects returned by this query will have `path` and `depth` attributes. 125 | The results will be ordered by `path` (hierarchically by region name). The SQL 126 | produced by this query looks something like this: 127 | 128 | ```sql 129 | WITH RECURSIVE "cte" AS ( 130 | SELECT 131 | "region"."name", 132 | "region"."name" AS "path", 133 | 0 AS "depth" 134 | FROM "region" 135 | WHERE "region"."parent_id" IS NULL 136 | 137 | UNION ALL 138 | 139 | SELECT 140 | "region"."name", 141 | "cte"."path" || ' / ' || "region"."name" AS "path", 142 | "cte"."depth" + 1 AS "depth" 143 | FROM "region" 144 | INNER JOIN "cte" ON "region"."parent_id" = "cte"."name" 145 | ) 146 | SELECT 147 | "region"."name", 148 | "region"."parent_id", 149 | "cte"."path" AS "path", 150 | "cte"."depth" AS "depth" 151 | FROM "region" 152 | INNER JOIN "cte" ON "region"."name" = "cte"."name" 153 | WHERE "cte"."depth" = 2 154 | ORDER BY "path" ASC 155 | ``` 156 | 157 | 158 | ## Named Common Table Expressions 159 | 160 | It is possible to add more than one CTE to a query. To do this, each CTE must 161 | have a unique name. `CTE(queryset)` returns a CTE with the name `'cte'` by 162 | default, but that can be overridden: `CTE(queryset, name='custom')` or 163 | `CTE.recursive(make_queryset, name='custom')`. This allows each CTE to be 164 | referenced uniquely within a single query. 165 | 166 | Also note that a CTE may reference other CTEs in the same query. 167 | 168 | Example query with two CTEs, and the second (`totals`) CTE references the first 169 | (`rootmap`): 170 | 171 | ```py 172 | def make_root_mapping(rootmap): 173 | return Region.objects.filter( 174 | parent__isnull=True 175 | ).values( 176 | "name", 177 | root=F("name"), 178 | ).union( 179 | rootmap.join(Region, parent=rootmap.col.name).values( 180 | "name", 181 | root=rootmap.col.root, 182 | ), 183 | all=True, 184 | ) 185 | rootmap = CTE.recursive(make_root_mapping, name="rootmap") 186 | 187 | totals = CTE( 188 | rootmap.join(Order, region_id=rootmap.col.name) 189 | .values( 190 | root=rootmap.col.root, 191 | ).annotate( 192 | orders_count=Count("id"), 193 | region_total=Sum("amount"), 194 | ), 195 | name="totals", 196 | ) 197 | 198 | root_regions = with_cte( 199 | # Important: add both CTEs to the query 200 | rootmap, 201 | totals, 202 | 203 | select=totals.join(Region, name=totals.col.root) 204 | .annotate( 205 | # count of orders in this region and all subregions 206 | orders_count=totals.col.orders_count, 207 | # sum of order amounts in this region and all subregions 208 | region_total=totals.col.region_total, 209 | ) 210 | ) 211 | ``` 212 | 213 | And the resulting SQL. 214 | 215 | ```sql 216 | WITH RECURSIVE "rootmap" AS ( 217 | SELECT 218 | "region"."name", 219 | "region"."name" AS "root" 220 | FROM "region" 221 | WHERE "region"."parent_id" IS NULL 222 | 223 | UNION ALL 224 | 225 | SELECT 226 | "region"."name", 227 | "rootmap"."root" AS "root" 228 | FROM "region" 229 | INNER JOIN "rootmap" ON "region"."parent_id" = "rootmap"."name" 230 | ), 231 | "totals" AS ( 232 | SELECT 233 | "rootmap"."root" AS "root", 234 | COUNT("orders"."id") AS "orders_count", 235 | SUM("orders"."amount") AS "region_total" 236 | FROM "orders" 237 | INNER JOIN "rootmap" ON "orders"."region_id" = "rootmap"."name" 238 | GROUP BY "rootmap"."root" 239 | ) 240 | SELECT 241 | "region"."name", 242 | "region"."parent_id", 243 | "totals"."orders_count" AS "orders_count", 244 | "totals"."region_total" AS "region_total" 245 | FROM "region" 246 | INNER JOIN "totals" ON "region"."name" = "totals"."root" 247 | ``` 248 | 249 | 250 | ## Selecting FROM a Common Table Expression 251 | 252 | Sometimes it is useful to construct queries where the final `FROM` clause 253 | contains only common table expression(s). This is possible with 254 | `CTE(...).queryset()`. 255 | 256 | Each returned row may be a model object: 257 | 258 | ```py 259 | cte = CTE( 260 | Order.objects 261 | .annotate(region_parent=F("region__parent_id")), 262 | ) 263 | orders = with_cte(cte, select=cte.queryset()) 264 | ``` 265 | 266 | And the resulting SQL: 267 | 268 | ```sql 269 | WITH RECURSIVE "cte" AS ( 270 | SELECT 271 | "orders"."id", 272 | "orders"."region_id", 273 | "orders"."amount", 274 | "region"."parent_id" AS "region_parent" 275 | FROM "orders" 276 | INNER JOIN "region" ON "orders"."region_id" = "region"."name" 277 | ) 278 | SELECT 279 | "cte"."id", 280 | "cte"."region_id", 281 | "cte"."amount", 282 | "cte"."region_parent" AS "region_parent" 283 | FROM "cte" 284 | ``` 285 | 286 | It is also possible to do the same with `values(...)` queries: 287 | 288 | ```py 289 | cte = CTE( 290 | Order.objects 291 | .values( 292 | "region_id", 293 | region_parent=F("region__parent_id"), 294 | ) 295 | .distinct() 296 | ) 297 | values = with_cte(cte, select=cte).filter(region_parent__isnull=False) 298 | ``` 299 | 300 | Which produces this SQL: 301 | 302 | ```sql 303 | WITH RECURSIVE "cte" AS ( 304 | SELECT DISTINCT 305 | "orders"."region_id", 306 | "region"."parent_id" AS "region_parent" 307 | FROM "orders" 308 | INNER JOIN "region" ON "orders"."region_id" = "region"."name" 309 | ) 310 | SELECT 311 | "cte"."region_id", 312 | "cte"."region_parent" AS "region_parent" 313 | FROM "cte" 314 | WHERE "cte"."region_parent" IS NOT NULL 315 | ``` 316 | 317 | You may have noticed that when a CTE is passed to the `select=...` argument as 318 | in `with_cte(cte, select=cte)`, the `.queryset()` call is optional and may be 319 | omitted. 320 | 321 | 322 | ## Experimental: Left Outer Join 323 | 324 | Django does not provide precise control over joins, but there is an experimental 325 | way to perform a `LEFT OUTER JOIN` with a CTE query using the `_join_type` 326 | keyword argument of `CTE.join(...)`. 327 | 328 | ```py 329 | from django.db.models.sql.constants import LOUTER 330 | 331 | totals = CTE( 332 | Order.objects 333 | .values("region_id") 334 | .annotate(total=Sum("amount")) 335 | .filter(total__gt=100) 336 | ) 337 | orders = with_cte( 338 | totals, 339 | select=totals 340 | .join(Order, region=totals.col.region_id, _join_type=LOUTER) 341 | .annotate(region_total=totals.col.total) 342 | ) 343 | ``` 344 | 345 | Which produces the following SQL 346 | 347 | ```sql 348 | WITH RECURSIVE "cte" AS ( 349 | SELECT 350 | "orders"."region_id", 351 | SUM("orders"."amount") AS "total" 352 | FROM "orders" 353 | GROUP BY "orders"."region_id" 354 | HAVING SUM("orders"."amount") > 100 355 | ) 356 | SELECT 357 | "orders"."id", 358 | "orders"."region_id", 359 | "orders"."amount", 360 | "cte"."total" AS "region_total" 361 | FROM "orders" 362 | LEFT OUTER JOIN "cte" ON "orders"."region_id" = "cte"."region_id" 363 | ``` 364 | 365 | WARNING: as noted, this feature is experimental. There may be scenarios where 366 | Django automatically converts a `LEFT OUTER JOIN` to an `INNER JOIN` in the 367 | process of building the query. Be sure to test your queries to ensure they 368 | produce the desired SQL. 369 | 370 | 371 | ## Materialized CTE 372 | 373 | Both PostgreSQL 12+ and sqlite 3.35+ supports `MATERIALIZED` keyword for CTE 374 | queries. To enforce usage of this keyword add `materialized` as a parameter of 375 | `CTE(..., materialized=True)`. 376 | 377 | 378 | ```py 379 | cte = CTE( 380 | Order.objects.values('id'), 381 | materialized=True 382 | ) 383 | ``` 384 | 385 | Which produces this SQL: 386 | 387 | ```sql 388 | WITH RECURSIVE "cte" AS MATERIALIZED ( 389 | SELECT 390 | "orders"."id" 391 | FROM "orders" 392 | ) 393 | ... 394 | ``` 395 | 396 | 397 | ## Raw CTE SQL 398 | 399 | Some queries are easier to construct with raw SQL than with the Django ORM. 400 | `raw_cte_sql()` is one solution for situations like that. The down-side is that 401 | each result field in the raw query must be explicitly mapped to a field type. 402 | The up-side is that there is no need to compromise result-set expressiveness 403 | with the likes of `Manager.raw()`. 404 | 405 | A short example: 406 | 407 | ```py 408 | from django.db.models import IntegerField, TextField 409 | from django_cte.raw import raw_cte_sql 410 | 411 | cte = CTE(raw_cte_sql( 412 | """ 413 | SELECT region_id, AVG(amount) AS avg_order 414 | FROM orders 415 | WHERE region_id = %s 416 | GROUP BY region_id 417 | """, 418 | ["moon"], 419 | { 420 | "region_id": TextField(), 421 | "avg_order": IntegerField(), 422 | }, 423 | )) 424 | moon_avg = with_cte( 425 | cte, 426 | select=cte 427 | .join(Region, name=cte.col.region_id) 428 | .annotate(avg_order=cte.col.avg_order) 429 | ) 430 | ``` 431 | 432 | Which produces this SQL: 433 | 434 | ```sql 435 | WITH RECURSIVE "cte" AS ( 436 | SELECT region_id, AVG(amount) AS avg_order 437 | FROM orders 438 | WHERE region_id = 'moon' 439 | GROUP BY region_id 440 | ) 441 | SELECT 442 | "region"."name", 443 | "region"."parent_id", 444 | "cte"."avg_order" AS "avg_order" 445 | FROM "region" 446 | INNER JOIN "cte" ON "region"."name" = "cte"."region_id" 447 | ``` 448 | 449 | **WARNING**: Be very careful when writing raw SQL. Use bind parameters to 450 | prevent SQL injection attacks. 451 | 452 | 453 | ## More Advanced Use Cases 454 | 455 | A few more advanced techniques as well as example query results can be found 456 | in the tests: 457 | 458 | - [`test_cte.py`](https://github.com/dimagi/django-cte/blob/main/tests/test_cte.py) 459 | - [`test_recursive.py`](https://github.com/dimagi/django-cte/blob/main/tests/test_recursive.py) 460 | - [`test_raw.py`](https://github.com/dimagi/django-cte/blob/main/tests/test_raw.py) 461 | 462 | 463 | ## Appendix A: Model definitions used in sample code 464 | 465 | ```py 466 | class Order(Model): 467 | id = AutoField(primary_key=True) 468 | region = ForeignKey("Region", on_delete=CASCADE) 469 | amount = IntegerField(default=0) 470 | 471 | class Meta: 472 | db_table = "orders" 473 | 474 | 475 | class Region(Model): 476 | name = TextField(primary_key=True) 477 | parent = ForeignKey("self", null=True, on_delete=CASCADE) 478 | 479 | class Meta: 480 | db_table = "region" 481 | ``` 482 | 483 | 484 | ## Appendix B: django-cte v1 documentation (DEPRECATED) 485 | 486 | The syntax for constructing CTE queries changed slightly in django-cte 2.0. The 487 | most important change is that a custom model manager is no longer required on 488 | models used to construct CTE queries. The documentation has been updated to use 489 | v2 syntax, but the [documentation for v1](https://github.com/dimagi/django-cte/blob/v1.3.3/docs/index.md) 490 | can be found on Github if needed. 491 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "django-cte" 3 | description = "Common Table Expressions (CTE) for Django" 4 | authors = [{name = "Daniel Miller", email = "millerdev@gmail.com"}] 5 | license = {file = "LICENSE"} 6 | readme = {file = "README.md", content-type = "text/markdown"} 7 | dynamic = ["version"] 8 | requires-python = ">= 3.9" 9 | # Python and Django versions are read from this file by GitHub Actions. 10 | # Precise formatting is important. 11 | classifiers = [ 12 | "Development Status :: 5 - Production/Stable", 13 | 'Environment :: Web Environment', 14 | 'Framework :: Django', 15 | 'Intended Audience :: Developers', 16 | 'License :: OSI Approved :: BSD License', 17 | 'Operating System :: OS Independent', 18 | 'Programming Language :: Python', 19 | 'Programming Language :: Python :: 3', 20 | 'Programming Language :: Python :: 3.9', 21 | 'Programming Language :: Python :: 3.10', 22 | 'Programming Language :: Python :: 3.11', 23 | 'Programming Language :: Python :: 3.12', 24 | 'Programming Language :: Python :: 3.13', 25 | 'Programming Language :: Python :: 3.14', 26 | 'Framework :: Django', 27 | 'Framework :: Django :: 4', 28 | 'Framework :: Django :: 4.2', 29 | 'Framework :: Django :: 5', 30 | 'Framework :: Django :: 5.1', 31 | 'Framework :: Django :: 5.2', 32 | 'Topic :: Software Development :: Libraries :: Python Modules', 33 | ] 34 | dependencies = ["django"] 35 | 36 | [dependency-groups] 37 | dev = [ 38 | "psycopg2-binary", 39 | "pytest-unmagic", 40 | "ruff", 41 | ] 42 | 43 | [project.urls] 44 | Home = "https://github.com/dimagi/django-cte" 45 | 46 | [build-system] 47 | requires = ["flit_core >=3.2,<4"] 48 | build-backend = "flit_core.buildapi" 49 | 50 | [tool.flit.module] 51 | name = "django_cte" 52 | 53 | [tool.distutils.bdist_wheel] 54 | universal = true 55 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import warnings 3 | from contextlib import contextmanager 4 | 5 | import django 6 | from unmagic import fixture 7 | 8 | # django setup must occur before importing models 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") 10 | django.setup() 11 | 12 | from .django_setup import init_db, destroy_db # noqa 13 | 14 | 15 | @fixture(autouse=__file__, scope="package") 16 | def test_db(): 17 | with ignore_v1_warnings(): 18 | init_db() 19 | yield 20 | destroy_db() 21 | 22 | 23 | @contextmanager 24 | def ignore_v1_warnings(): 25 | msg = ( 26 | r"CTE(Manager|QuerySet) is deprecated.*" 27 | r"|" 28 | r"Use `django_cte\.with_cte\(.*\)` instead\." 29 | r"|" 30 | r"Use `django_cte\.CTE(\.recursive)?` instead\." 31 | ) 32 | with warnings.catch_warnings(): 33 | warnings.filterwarnings("ignore", message=msg, category=DeprecationWarning) 34 | yield 35 | -------------------------------------------------------------------------------- /tests/django_setup.py: -------------------------------------------------------------------------------- 1 | from django.db import connection 2 | 3 | from .models import KeyPair, Region, Order 4 | 5 | is_initialized = False 6 | 7 | 8 | def init_db(): 9 | global is_initialized 10 | if is_initialized: 11 | return 12 | is_initialized = True 13 | 14 | connection.creation.create_test_db(verbosity=0, autoclobber=True) 15 | 16 | setup_data() 17 | 18 | 19 | def destroy_db(): 20 | connection.creation.destroy_test_db(verbosity=0) 21 | 22 | 23 | def setup_data(): 24 | regions = {None: None} 25 | for name, parent in [ 26 | ("sun", None), 27 | ("mercury", "sun"), 28 | ("venus", "sun"), 29 | ("earth", "sun"), 30 | ("moon", "earth"), 31 | ("mars", "sun"), 32 | ("deimos", "mars"), 33 | ("phobos", "mars"), 34 | ("proxima centauri", None), 35 | ("proxima centauri b", "proxima centauri"), 36 | ("bernard's star", None), 37 | ]: 38 | region = Region(name=name, parent=regions[parent]) 39 | region.save() 40 | regions[name] = region 41 | 42 | for region, amount in [ 43 | ("sun", 1000), 44 | ("mercury", 10), 45 | ("mercury", 11), 46 | ("mercury", 12), 47 | ("venus", 20), 48 | ("venus", 21), 49 | ("venus", 22), 50 | ("venus", 23), 51 | ("earth", 30), 52 | ("earth", 31), 53 | ("earth", 32), 54 | ("earth", 33), 55 | ("moon", 1), 56 | ("moon", 2), 57 | ("moon", 3), 58 | ("mars", 40), 59 | ("mars", 41), 60 | ("mars", 42), 61 | ("proxima centauri", 2000), 62 | ("proxima centauri b", 10), 63 | ("proxima centauri b", 11), 64 | ("proxima centauri b", 12), 65 | ]: 66 | order = Order(amount=amount, region=regions[region]) 67 | order.save() 68 | 69 | for key, value, parent in [ 70 | ("level 1", 1, None), 71 | ("level 2", 1, "level 1"), 72 | ("level 2", 2, "level 1"), 73 | ("level 3", 1, "level 2"), 74 | ]: 75 | parent = parent and KeyPair.objects.filter(key=parent).first() 76 | KeyPair.objects.create(key=key, value=value, parent=parent) 77 | -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from django.db.models import ( 2 | CASCADE, 3 | Manager, 4 | Model, 5 | QuerySet, 6 | AutoField, 7 | CharField, 8 | ForeignKey, 9 | IntegerField, 10 | TextField, 11 | ) 12 | 13 | 14 | class LT40QuerySet(QuerySet): 15 | 16 | def lt40(self): 17 | return self.filter(amount__lt=40) 18 | 19 | 20 | class LT25QuerySet(QuerySet): 21 | 22 | def lt25(self): 23 | return self.filter(amount__lt=25) 24 | 25 | 26 | class Region(Model): 27 | name = TextField(primary_key=True) 28 | parent = ForeignKey("self", null=True, on_delete=CASCADE) 29 | 30 | class Meta: 31 | db_table = "region" 32 | 33 | 34 | class User(Model): 35 | id = AutoField(primary_key=True) 36 | name = TextField() 37 | 38 | class Meta: 39 | db_table = "user" 40 | 41 | 42 | class Order(Model): 43 | id = AutoField(primary_key=True) 44 | region = ForeignKey(Region, on_delete=CASCADE) 45 | amount = IntegerField(default=0) 46 | user = ForeignKey(User, null=True, on_delete=CASCADE) 47 | 48 | class Meta: 49 | db_table = "orders" 50 | 51 | 52 | class OrderFromLT40(Order): 53 | class Meta: 54 | proxy = True 55 | objects = Manager.from_queryset(LT40QuerySet)() 56 | 57 | 58 | class OrderCustomManagerNQuery(Order): 59 | class Meta: 60 | proxy = True 61 | objects = Manager.from_queryset(LT25QuerySet)() 62 | 63 | 64 | class KeyPair(Model): 65 | key = CharField(max_length=32) 66 | value = IntegerField(default=0) 67 | parent = ForeignKey("self", null=True, on_delete=CASCADE) 68 | 69 | class Meta: 70 | db_table = "keypair" 71 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | 4 | if "DB_SETTINGS" in os.environ: 5 | _db_settings = json.loads(os.environ["DB_SETTINGS"]) 6 | else: 7 | # sqlite3 by default 8 | # must be sqlite3 >= 3.8.3 supporting WITH clause 9 | # must be sqlite3 >= 3.35.0 supporting MATERIALIZED option 10 | _db_settings = { 11 | "ENGINE": "django.db.backends.sqlite3", 12 | "NAME": ":memory:", 13 | } 14 | 15 | DATABASES = {'default': _db_settings} 16 | 17 | INSTALLED_APPS = ["tests"] 18 | 19 | SECRET_KEY = "test" 20 | USE_TZ = False 21 | -------------------------------------------------------------------------------- /tests/test_combinators.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.db.models import Value 3 | from django.db.models.aggregates import Sum 4 | from django.test import TestCase 5 | 6 | from django_cte import CTE, with_cte 7 | 8 | from .models import Order 9 | 10 | 11 | class TestCTECombinators(TestCase): 12 | 13 | def test_cte_union_query(self): 14 | one = CTE( 15 | Order.objects 16 | .values("region_id") 17 | .annotate(total=Sum("amount")), 18 | name="one" 19 | ) 20 | two = CTE( 21 | Order.objects 22 | .values("region_id") 23 | .annotate(total=Sum("amount") * 2), 24 | name="two" 25 | ) 26 | 27 | earths = with_cte( 28 | one, 29 | select=one.join( 30 | Order.objects.filter(region_id="earth"), 31 | region=one.col.region_id 32 | ) 33 | .annotate(region_total=one.col.total) 34 | .values_list("amount", "region_id", "region_total") 35 | ) 36 | mars = with_cte( 37 | two, 38 | select=two.join( 39 | Order.objects.filter(region_id="mars"), 40 | region=two.col.region_id 41 | ) 42 | .annotate(region_total=two.col.total) 43 | .values_list("amount", "region_id", "region_total") 44 | ) 45 | combined = earths.union(mars, all=True) 46 | print(combined.query) 47 | 48 | self.assertEqual(sorted(combined), [ 49 | (30, 'earth', 126), 50 | (31, 'earth', 126), 51 | (32, 'earth', 126), 52 | (33, 'earth', 126), 53 | (40, 'mars', 246), 54 | (41, 'mars', 246), 55 | (42, 'mars', 246), 56 | ]) 57 | 58 | # queries used in union should still work on their own 59 | print(earths.query) 60 | self.assertEqual(sorted(earths),[ 61 | (30, 'earth', 126), 62 | (31, 'earth', 126), 63 | (32, 'earth', 126), 64 | (33, 'earth', 126), 65 | ]) 66 | print(mars.query) 67 | self.assertEqual(sorted(mars),[ 68 | (40, 'mars', 246), 69 | (41, 'mars', 246), 70 | (42, 'mars', 246), 71 | ]) 72 | 73 | def test_cte_union_with_non_cte_query(self): 74 | one = CTE( 75 | Order.objects 76 | .values("region_id") 77 | .annotate(total=Sum("amount")), 78 | ) 79 | 80 | earths = with_cte( 81 | one, 82 | select=one.join( 83 | Order.objects.filter(region_id="earth"), 84 | region=one.col.region_id 85 | ).annotate(region_total=one.col.total) 86 | ) 87 | plain_mars = ( 88 | Order.objects.filter(region_id="mars") 89 | .annotate(region_total=Value(0)) 90 | ) 91 | # Note: this does not work in the opposite order. A CTE query 92 | # must come first to invoke custom CTE combinator logic. 93 | combined = earths.union(plain_mars, all=True) \ 94 | .values_list("amount", "region_id", "region_total") 95 | print(combined.query) 96 | 97 | self.assertEqual(sorted(combined), [ 98 | (30, 'earth', 126), 99 | (31, 'earth', 126), 100 | (32, 'earth', 126), 101 | (33, 'earth', 126), 102 | (40, 'mars', 0), 103 | (41, 'mars', 0), 104 | (42, 'mars', 0), 105 | ]) 106 | 107 | def test_cte_union_with_duplicate_names(self): 108 | cte_sun = CTE( 109 | Order.objects 110 | .filter(region__parent="sun") 111 | .values("region_id") 112 | .annotate(total=Sum("amount")), 113 | ) 114 | cte_proxima = CTE( 115 | Order.objects 116 | .filter(region__parent="proxima centauri") 117 | .values("region_id") 118 | .annotate(total=2 * Sum("amount")), 119 | ) 120 | 121 | orders_sun = with_cte( 122 | cte_sun, 123 | select=cte_sun.join(Order, region=cte_sun.col.region_id) 124 | .annotate(region_total=cte_sun.col.total) 125 | ) 126 | orders_proxima = with_cte( 127 | cte_proxima, 128 | select=cte_proxima.join(Order, region=cte_proxima.col.region_id) 129 | .annotate(region_total=cte_proxima.col.total) 130 | ) 131 | 132 | msg = "Found two or more CTEs named 'cte'" 133 | with pytest.raises(ValueError, match=msg): 134 | orders_sun.union(orders_proxima) 135 | 136 | def test_cte_union_of_same_cte(self): 137 | cte = CTE( 138 | Order.objects 139 | .filter(region__parent="sun") 140 | .values("region_id") 141 | .annotate(total=Sum("amount")), 142 | ) 143 | 144 | orders_big = with_cte( 145 | cte, 146 | select=cte.join(Order, region=cte.col.region_id) 147 | .annotate(region_total=3 * cte.col.total) 148 | ) 149 | orders_small = with_cte( 150 | cte, 151 | select=cte.join(Order, region=cte.col.region_id) 152 | .annotate(region_total=cte.col.total) 153 | ) 154 | 155 | orders = orders_big.union(orders_small) \ 156 | .values_list("amount", "region_id", "region_total") 157 | print(orders.query) 158 | 159 | self.assertEqual(sorted(orders), [ 160 | (10, 'mercury', 33), 161 | (10, 'mercury', 99), 162 | (11, 'mercury', 33), 163 | (11, 'mercury', 99), 164 | (12, 'mercury', 33), 165 | (12, 'mercury', 99), 166 | (20, 'venus', 86), 167 | (20, 'venus', 258), 168 | (21, 'venus', 86), 169 | (21, 'venus', 258), 170 | (22, 'venus', 86), 171 | (22, 'venus', 258), 172 | (23, 'venus', 86), 173 | (23, 'venus', 258), 174 | (30, 'earth', 126), 175 | (30, 'earth', 378), 176 | (31, 'earth', 126), 177 | (31, 'earth', 378), 178 | (32, 'earth', 126), 179 | (32, 'earth', 378), 180 | (33, 'earth', 126), 181 | (33, 'earth', 378), 182 | (40, 'mars', 123), 183 | (40, 'mars', 369), 184 | (41, 'mars', 123), 185 | (41, 'mars', 369), 186 | (42, 'mars', 123), 187 | (42, 'mars', 369) 188 | ]) 189 | 190 | def test_cte_intersection(self): 191 | cte_big = CTE( 192 | Order.objects 193 | .values("region_id") 194 | .annotate(total=Sum("amount")), 195 | name='big' 196 | ) 197 | cte_small = CTE( 198 | Order.objects 199 | .values("region_id") 200 | .annotate(total=Sum("amount")), 201 | name='small' 202 | ) 203 | orders_big = with_cte( 204 | cte_big, 205 | select=cte_big.join(Order, region=cte_big.col.region_id) 206 | .annotate(region_total=cte_big.col.total) 207 | .filter(region_total__gte=86) 208 | ) 209 | orders_small = with_cte( 210 | cte_small, 211 | select=cte_small.join(Order, region=cte_small.col.region_id) 212 | .annotate(region_total=cte_small.col.total) 213 | .filter(region_total__lte=123) 214 | ) 215 | 216 | orders = orders_small.intersection(orders_big) \ 217 | .values_list("amount", "region_id", "region_total") 218 | print(orders.query) 219 | 220 | self.assertEqual(sorted(orders), [ 221 | (20, 'venus', 86), 222 | (21, 'venus', 86), 223 | (22, 'venus', 86), 224 | (23, 'venus', 86), 225 | (40, 'mars', 123), 226 | (41, 'mars', 123), 227 | (42, 'mars', 123), 228 | ]) 229 | 230 | def test_cte_difference(self): 231 | cte_big = CTE( 232 | Order.objects 233 | .values("region_id") 234 | .annotate(total=Sum("amount")), 235 | name='big' 236 | ) 237 | cte_small = CTE( 238 | Order.objects 239 | .values("region_id") 240 | .annotate(total=Sum("amount")), 241 | name='small' 242 | ) 243 | orders_big = with_cte( 244 | cte_big, 245 | select=cte_big.join(Order, region=cte_big.col.region_id) 246 | .annotate(region_total=cte_big.col.total) 247 | .filter(region_total__gte=86) 248 | ) 249 | orders_small = with_cte( 250 | cte_small, 251 | select=cte_small.join(Order, region=cte_small.col.region_id) 252 | .annotate(region_total=cte_small.col.total) 253 | .filter(region_total__lte=123) 254 | ) 255 | 256 | orders = orders_small.difference(orders_big) \ 257 | .values_list("amount", "region_id", "region_total") 258 | print(orders.query) 259 | 260 | self.assertEqual(sorted(orders), [ 261 | (1, 'moon', 6), 262 | (2, 'moon', 6), 263 | (3, 'moon', 6), 264 | (10, 'mercury', 33), 265 | (10, 'proxima centauri b', 33), 266 | (11, 'mercury', 33), 267 | (11, 'proxima centauri b', 33), 268 | (12, 'mercury', 33), 269 | (12, 'proxima centauri b', 33), 270 | ]) 271 | -------------------------------------------------------------------------------- /tests/test_cte.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.db.models import IntegerField, TextField 3 | from django.db.models.aggregates import Count, Max, Min, Sum 4 | from django.db.models.expressions import ( 5 | Exists, ExpressionWrapper, F, OuterRef, Subquery, 6 | ) 7 | from django.db.models.sql.constants import LOUTER 8 | from django.db.utils import OperationalError, ProgrammingError 9 | from django.test import TestCase 10 | 11 | from django_cte import CTE, with_cte 12 | 13 | from .models import Order, Region, User 14 | 15 | int_field = IntegerField() 16 | text_field = TextField() 17 | 18 | 19 | class TestCTE(TestCase): 20 | 21 | def test_simple_cte_query(self): 22 | cte = CTE( 23 | Order.objects 24 | .values("region_id") 25 | .annotate(total=Sum("amount")) 26 | ) 27 | 28 | orders = with_cte( 29 | # WITH cte ... 30 | cte, 31 | 32 | # SELECT ... FROM orders 33 | # INNER JOIN cte ON orders.region_id = cte.region_id 34 | select=cte.join(Order, region=cte.col.region_id), 35 | ).annotate(region_total=cte.col.total) 36 | print(orders.query) 37 | 38 | data = sorted((o.amount, o.region_id, o.region_total) for o in orders) 39 | self.assertEqual(data, [ 40 | (1, 'moon', 6), 41 | (2, 'moon', 6), 42 | (3, 'moon', 6), 43 | (10, 'mercury', 33), 44 | (10, 'proxima centauri b', 33), 45 | (11, 'mercury', 33), 46 | (11, 'proxima centauri b', 33), 47 | (12, 'mercury', 33), 48 | (12, 'proxima centauri b', 33), 49 | (20, 'venus', 86), 50 | (21, 'venus', 86), 51 | (22, 'venus', 86), 52 | (23, 'venus', 86), 53 | (30, 'earth', 126), 54 | (31, 'earth', 126), 55 | (32, 'earth', 126), 56 | (33, 'earth', 126), 57 | (40, 'mars', 123), 58 | (41, 'mars', 123), 59 | (42, 'mars', 123), 60 | (1000, 'sun', 1000), 61 | (2000, 'proxima centauri', 2000), 62 | ]) 63 | 64 | def test_cte_name_escape(self): 65 | totals = CTE( 66 | Order.objects 67 | .filter(region__parent="sun") 68 | .values("region_id") 69 | .annotate(total=Sum("amount")), 70 | name="mixedCaseCTEName" 71 | ) 72 | orders = with_cte( 73 | totals, 74 | select=totals.join(Order, region=totals.col.region_id) 75 | .annotate(region_total=totals.col.total) 76 | .order_by("amount") 77 | ) 78 | self.assertTrue( 79 | str(orders.query).startswith('WITH RECURSIVE "mixedCaseCTEName"')) 80 | 81 | def test_cte_queryset(self): 82 | sub_totals = CTE( 83 | Order.objects 84 | .values(region_parent=F("region__parent_id")) 85 | .annotate(total=Sum("amount")), 86 | ) 87 | regions = with_cte( 88 | sub_totals, 89 | select=Region.objects.annotate( 90 | child_regions_total=Subquery( 91 | sub_totals.queryset() 92 | .filter(region_parent=OuterRef("name")) 93 | .values("total"), 94 | ), 95 | ) 96 | .order_by("name") 97 | ) 98 | print(regions.query) 99 | 100 | data = [(r.name, r.child_regions_total) for r in regions] 101 | self.assertEqual(data, [ 102 | ("bernard's star", None), 103 | ('deimos', None), 104 | ('earth', 6), 105 | ('mars', None), 106 | ('mercury', None), 107 | ('moon', None), 108 | ('phobos', None), 109 | ('proxima centauri', 33), 110 | ('proxima centauri b', None), 111 | ('sun', 368), 112 | ('venus', None) 113 | ]) 114 | 115 | def test_cte_queryset_with_model_result(self): 116 | cte = CTE( 117 | Order.objects 118 | .annotate(region_parent=F("region__parent_id")), 119 | ) 120 | orders = with_cte( 121 | cte, # WITH cte AS (...) 122 | select=cte, # SELECT ... FROM cte 123 | ) 124 | print(orders.query) 125 | 126 | data = sorted( 127 | (x.region_id, x.amount, x.region_parent) for x in orders)[:5] 128 | self.assertEqual(data, [ 129 | ("earth", 30, "sun"), 130 | ("earth", 31, "sun"), 131 | ("earth", 32, "sun"), 132 | ("earth", 33, "sun"), 133 | ("mars", 40, "sun"), 134 | ]) 135 | self.assertTrue( 136 | all(isinstance(x, Order) for x in orders), 137 | repr([x for x in orders]), 138 | ) 139 | 140 | def test_cte_queryset_with_join(self): 141 | cte = CTE( 142 | Order.objects 143 | .annotate(region_parent=F("region__parent_id")), 144 | ) 145 | orders = with_cte( 146 | cte, 147 | select=cte.queryset() 148 | .annotate(parent=F("region__parent_id")) 149 | .order_by("region_id", "amount") 150 | ) 151 | print(orders.query) 152 | 153 | data = [(x.region_id, x.region_parent, x.parent) for x in orders][:5] 154 | self.assertEqual(data, [ 155 | ("earth", "sun", "sun"), 156 | ("earth", "sun", "sun"), 157 | ("earth", "sun", "sun"), 158 | ("earth", "sun", "sun"), 159 | ("mars", "sun", "sun"), 160 | ]) 161 | 162 | def test_cte_queryset_with_values_result(self): 163 | cte = CTE( 164 | Order.objects 165 | .values( 166 | "region_id", 167 | region_parent=F("region__parent_id"), 168 | ) 169 | .distinct() 170 | ) 171 | values = with_cte(cte, select=cte).filter(region_parent__isnull=False) 172 | print(values.query) 173 | 174 | def key(item): 175 | return item["region_parent"], item["region_id"] 176 | 177 | data = sorted(values, key=key)[:5] 178 | self.assertEqual(data, [ 179 | {'region_id': 'moon', 'region_parent': 'earth'}, 180 | { 181 | 'region_id': 'proxima centauri b', 182 | 'region_parent': 'proxima centauri', 183 | }, 184 | {'region_id': 'earth', 'region_parent': 'sun'}, 185 | {'region_id': 'mars', 'region_parent': 'sun'}, 186 | {'region_id': 'mercury', 'region_parent': 'sun'}, 187 | ]) 188 | 189 | def test_named_simple_ctes(self): 190 | totals = CTE( 191 | Order.objects 192 | .filter(region__parent="sun") 193 | .values("region_id") 194 | .annotate(total=Sum("amount")), 195 | name="totals", 196 | ) 197 | region_count = CTE( 198 | Region.objects 199 | .filter(parent="sun") 200 | .values("parent_id") 201 | .annotate(num=Count("name")), 202 | name="region_count", 203 | ) 204 | orders = with_cte( 205 | totals, 206 | region_count, 207 | select=region_count.join( 208 | totals.join(Order, region=totals.col.region_id), 209 | region__parent=region_count.col.parent_id 210 | ) 211 | .annotate(region_total=totals.col.total) 212 | .annotate(region_count=region_count.col.num) 213 | .order_by("amount") 214 | ) 215 | print(orders.query) 216 | 217 | data = [( 218 | o.amount, 219 | o.region_id, 220 | o.region_count, 221 | o.region_total, 222 | ) for o in orders] 223 | self.assertEqual(data, [ 224 | (10, 'mercury', 4, 33), 225 | (11, 'mercury', 4, 33), 226 | (12, 'mercury', 4, 33), 227 | (20, 'venus', 4, 86), 228 | (21, 'venus', 4, 86), 229 | (22, 'venus', 4, 86), 230 | (23, 'venus', 4, 86), 231 | (30, 'earth', 4, 126), 232 | (31, 'earth', 4, 126), 233 | (32, 'earth', 4, 126), 234 | (33, 'earth', 4, 126), 235 | (40, 'mars', 4, 123), 236 | (41, 'mars', 4, 123), 237 | (42, 'mars', 4, 123), 238 | ]) 239 | 240 | def test_named_ctes(self): 241 | def make_root_mapping(rootmap): 242 | return Region.objects.filter( 243 | parent__isnull=True 244 | ).values( 245 | "name", 246 | root=F("name"), 247 | ).union( 248 | rootmap.join(Region, parent=rootmap.col.name).values( 249 | "name", 250 | root=rootmap.col.root, 251 | ), 252 | all=True, 253 | ) 254 | rootmap = CTE.recursive(make_root_mapping, name="rootmap") 255 | 256 | totals = CTE( 257 | rootmap.join(Order, region_id=rootmap.col.name) 258 | .values( 259 | root=rootmap.col.root, 260 | ).annotate( 261 | orders_count=Count("id"), 262 | region_total=Sum("amount"), 263 | ), 264 | name="totals", 265 | ) 266 | 267 | root_regions = with_cte( 268 | rootmap, 269 | totals, 270 | select=totals.join(Region, name=totals.col.root).annotate( 271 | # count of orders in this region and all subregions 272 | orders_count=totals.col.orders_count, 273 | # sum of order amounts in this region and all subregions 274 | region_total=totals.col.region_total, 275 | ) 276 | ) 277 | print(root_regions.query) 278 | 279 | data = sorted( 280 | (r.name, r.orders_count, r.region_total) for r in root_regions 281 | ) 282 | self.assertEqual(data, [ 283 | ('proxima centauri', 4, 2033), 284 | ('sun', 18, 1374), 285 | ]) 286 | 287 | def test_materialized_option(self): 288 | totals = CTE( 289 | Order.objects 290 | .filter(region__parent="sun") 291 | .values("region_id") 292 | .annotate(total=Sum("amount")), 293 | materialized=True 294 | ) 295 | orders = with_cte( 296 | totals, 297 | select=totals.join(Order, region=totals.col.region_id) 298 | .annotate(region_total=totals.col.total) 299 | .order_by("amount") 300 | ) 301 | self.assertTrue( 302 | str(orders.query).startswith( 303 | 'WITH RECURSIVE "cte" AS MATERIALIZED' 304 | ) 305 | ) 306 | 307 | def test_update_cte_query(self): 308 | cte = CTE( 309 | Order.objects 310 | .values(region_parent=F("region__parent_id")) 311 | .annotate(total=Sum("amount")) 312 | .filter(total__isnull=False) 313 | ) 314 | # not the most efficient query, but it exercises CTEUpdateQuery 315 | with_cte(cte, select=Order).filter(region_id__in=Subquery( 316 | cte.queryset() 317 | .filter(region_parent=OuterRef("region_id")) 318 | .values("region_parent") 319 | )).update(amount=Subquery( 320 | cte.queryset() 321 | .filter(region_parent=OuterRef("region_id")) 322 | .values("total") 323 | )) 324 | 325 | data = set((o.region_id, o.amount) for o in Order.objects.filter( 326 | region_id__in=["earth", "sun", "proxima centauri", "mars"] 327 | )) 328 | self.assertEqual(data, { 329 | ('earth', 6), 330 | ('mars', 40), 331 | ('mars', 41), 332 | ('mars', 42), 333 | ('proxima centauri', 33), 334 | ('sun', 368), 335 | }) 336 | 337 | def test_update_with_subquery(self): 338 | # Test for issue: https://github.com/dimagi/django-cte/issues/9 339 | # Issue is not reproduced on sqlite3, use postgres to run. 340 | # To reproduce the problem it's required to have some join 341 | # in the select-query so the compiler will turn it into a subquery. 342 | # To add a join use a filter over field of related model 343 | orders = Order.objects.filter(region__parent_id='sun') 344 | orders.update(amount=0) 345 | data = {(order.region_id, order.amount) for order in orders} 346 | self.assertEqual(data, { 347 | ('mercury', 0), 348 | ('venus', 0), 349 | ('earth', 0), 350 | ('mars', 0), 351 | }) 352 | 353 | @pytest.mark.xfail( 354 | reason="this test will not work until `QuerySet.delete` " 355 | "(Django method) calls `self.query.chain(sql.DeleteQuery)` " 356 | "instead of `sql.DeleteQuery(self.model)`", 357 | raises=(OperationalError, ProgrammingError), 358 | strict=True, 359 | ) 360 | def test_delete_cte_query(self): 361 | cte = CTE( 362 | Order.objects 363 | .values(region_parent=F("region__parent_id")) 364 | .annotate(total=Sum("amount")) 365 | .filter(total__isnull=False) 366 | ) 367 | with_cte(cte, select=Order).annotate( 368 | cte_has_order=Exists( 369 | cte.queryset() 370 | .values("total") 371 | .filter(region_parent=OuterRef("region_id")) 372 | ) 373 | ).filter(cte_has_order=False).delete() 374 | 375 | data = [(o.region_id, o.amount) for o in Order.objects.all()] 376 | self.assertEqual(data, [ 377 | ('sun', 1000), 378 | ('earth', 30), 379 | ('earth', 31), 380 | ('earth', 32), 381 | ('earth', 33), 382 | ('proxima centauri', 2000), 383 | ]) 384 | 385 | def test_outerref_in_cte_query(self): 386 | # This query is meant to return the difference between min and max 387 | # order of each region, through a subquery 388 | min_and_max = CTE( 389 | Order.objects 390 | .filter(region=OuterRef("pk")) 391 | .values('region') # This is to force group by region_id 392 | .annotate( 393 | amount_min=Min("amount"), 394 | amount_max=Max("amount"), 395 | ) 396 | .values('amount_min', 'amount_max') 397 | ) 398 | regions = ( 399 | Region.objects 400 | .annotate( 401 | difference=Subquery( 402 | with_cte(min_and_max, select=min_and_max) 403 | .annotate( 404 | difference=ExpressionWrapper( 405 | F('amount_max') - F('amount_min'), 406 | output_field=int_field, 407 | ), 408 | ).values('difference')[:1], 409 | output_field=IntegerField() 410 | ) 411 | ) 412 | .order_by("name") 413 | ) 414 | print(regions.query) 415 | 416 | data = [(r.name, r.difference) for r in regions] 417 | self.assertEqual(data, [ 418 | ("bernard's star", None), 419 | ('deimos', None), 420 | ('earth', 3), 421 | ('mars', 2), 422 | ('mercury', 2), 423 | ('moon', 2), 424 | ('phobos', None), 425 | ('proxima centauri', 0), 426 | ('proxima centauri b', 2), 427 | ('sun', 0), 428 | ('venus', 3) 429 | ]) 430 | 431 | def test_experimental_left_outer_join(self): 432 | totals = CTE( 433 | Order.objects 434 | .values("region_id") 435 | .annotate(total=Sum("amount")) 436 | .filter(total__gt=100) 437 | ) 438 | orders = with_cte( 439 | totals, 440 | select=totals 441 | .join(Order, region=totals.col.region_id, _join_type=LOUTER) 442 | .annotate(region_total=totals.col.total) 443 | ) 444 | print(orders.query) 445 | self.assertIn("LEFT OUTER JOIN", str(orders.query)) 446 | self.assertNotIn("INNER JOIN", str(orders.query)) 447 | 448 | data = sorted((o.region_id, o.amount, o.region_total) for o in orders) 449 | self.assertEqual(data, [ 450 | ('earth', 30, 126), 451 | ('earth', 31, 126), 452 | ('earth', 32, 126), 453 | ('earth', 33, 126), 454 | ('mars', 40, 123), 455 | ('mars', 41, 123), 456 | ('mars', 42, 123), 457 | ('mercury', 10, None), 458 | ('mercury', 11, None), 459 | ('mercury', 12, None), 460 | ('moon', 1, None), 461 | ('moon', 2, None), 462 | ('moon', 3, None), 463 | ('proxima centauri', 2000, 2000), 464 | ('proxima centauri b', 10, None), 465 | ('proxima centauri b', 11, None), 466 | ('proxima centauri b', 12, None), 467 | ('sun', 1000, 1000), 468 | ('venus', 20, None), 469 | ('venus', 21, None), 470 | ('venus', 22, None), 471 | ('venus', 23, None), 472 | ]) 473 | 474 | def test_non_cte_subquery(self): 475 | """ 476 | Verifies that subquery annotations are handled correctly when the 477 | subquery model doesn't use the CTE manager, and the query results 478 | match expected behavior 479 | """ 480 | sub_totals = CTE( 481 | Order.objects 482 | .values(region_parent=F("region__parent_id")) 483 | .annotate( 484 | total=Sum("amount"), 485 | # trivial subquery example testing existence of 486 | # a user for the order 487 | non_cte_subquery=Exists( 488 | User.objects.filter(pk=OuterRef("user_id")) 489 | ), 490 | ), 491 | ) 492 | regions = with_cte( 493 | sub_totals, 494 | select=Region.objects.annotate( 495 | child_regions_total=Subquery( 496 | sub_totals.queryset() 497 | .filter(region_parent=OuterRef("name")) 498 | .values("total"), 499 | ), 500 | ) 501 | .order_by("name") 502 | ) 503 | print(regions.query) 504 | 505 | data = [(r.name, r.child_regions_total) for r in regions] 506 | self.assertEqual(data, [ 507 | ("bernard's star", None), 508 | ('deimos', None), 509 | ('earth', 6), 510 | ('mars', None), 511 | ('mercury', None), 512 | ('moon', None), 513 | ('phobos', None), 514 | ('proxima centauri', 33), 515 | ('proxima centauri b', None), 516 | ('sun', 368), 517 | ('venus', None) 518 | ]) 519 | 520 | def test_explain(self): 521 | """ 522 | Verifies that using .explain() prepends the EXPLAIN clause in the 523 | correct position 524 | """ 525 | 526 | totals = CTE( 527 | Order.objects 528 | .filter(region__parent="sun") 529 | .values("region_id") 530 | .annotate(total=Sum("amount")), 531 | name="totals", 532 | ) 533 | region_count = CTE( 534 | Region.objects 535 | .filter(parent="sun") 536 | .values("parent_id") 537 | .annotate(num=Count("name")), 538 | name="region_count", 539 | ) 540 | orders = with_cte( 541 | totals, 542 | region_count, 543 | select=region_count.join( 544 | totals.join(Order, region=totals.col.region_id), 545 | region__parent=region_count.col.parent_id 546 | ) 547 | .annotate(region_total=totals.col.total) 548 | .annotate(region_count=region_count.col.num) 549 | .order_by("amount") 550 | ) 551 | print(orders.query) 552 | 553 | self.assertIsInstance(orders.explain(), str) 554 | 555 | def test_empty_result_set_cte(self): 556 | """ 557 | Verifies that the CTEQueryCompiler can handle empty result sets in the 558 | related CTEs 559 | """ 560 | totals = CTE( 561 | Order.objects 562 | .filter(id__in=[]) 563 | .values("region_id") 564 | .annotate(total=Sum("amount")), 565 | name="totals", 566 | ) 567 | orders = with_cte( 568 | totals, 569 | select=totals.join(Order, region=totals.col.region_id) 570 | .annotate(region_total=totals.col.total) 571 | .order_by("amount") 572 | ) 573 | 574 | self.assertEqual(len(orders), 0) 575 | 576 | def test_left_outer_join_on_empty_result_set_cte(self): 577 | totals = CTE( 578 | Order.objects 579 | .filter(id__in=[]) 580 | .values("region_id") 581 | .annotate(total=Sum("amount")), 582 | name="totals", 583 | ) 584 | orders = with_cte( 585 | totals, 586 | select=totals 587 | .join(Order, region=totals.col.region_id, _join_type=LOUTER) 588 | .annotate(region_total=totals.col.total) 589 | .order_by("amount") 590 | ) 591 | 592 | self.assertEqual(len(orders), 22) 593 | 594 | def test_union_query_with_cte(self): 595 | orders = ( 596 | Order.objects 597 | .filter(region__parent="sun") 598 | .only("region", "amount") 599 | ) 600 | orders_cte = CTE(orders, name="orders_cte") 601 | orders_cte_queryset = orders_cte.queryset() 602 | 603 | earth_orders = orders_cte_queryset.filter(region="earth") 604 | mars_orders = orders_cte_queryset.filter(region="mars") 605 | 606 | earth_mars = earth_orders.union(mars_orders, all=True) 607 | earth_mars_cte = with_cte( 608 | orders_cte, 609 | select=earth_mars 610 | .order_by("region", "amount") 611 | .values_list("region", "amount") 612 | ) 613 | print(earth_mars_cte.query) 614 | 615 | self.assertEqual(list(earth_mars_cte), [ 616 | ('earth', 30), 617 | ('earth', 31), 618 | ('earth', 32), 619 | ('earth', 33), 620 | ('mars', 40), 621 | ('mars', 41), 622 | ('mars', 42), 623 | ]) 624 | 625 | def test_cte_select_pk(self): 626 | orders = Order.objects.filter(region="earth").values("pk") 627 | cte = CTE(orders) 628 | queryset = with_cte( 629 | cte, select=cte.join(orders, pk=cte.col.pk) 630 | ).order_by("pk") 631 | print(queryset.query) 632 | self.assertEqual(list(queryset), [ 633 | {'pk': 9}, 634 | {'pk': 10}, 635 | {'pk': 11}, 636 | {'pk': 12}, 637 | ]) 638 | 639 | def test_django52_resolve_ref_regression(self): 640 | cte = CTE( 641 | Order.objects.annotate( 642 | pnt_id=F("region__parent_id"), 643 | region_name=F("region__name"), 644 | ).values( 645 | # important: more than one query.select field 646 | "region_id", 647 | "amount", 648 | # important: more than one query.annotations field 649 | "pnt_id", 650 | "region_name", 651 | ) 652 | ) 653 | qs = with_cte( 654 | cte, 655 | select=cte.queryset() 656 | .values( 657 | amt=cte.col.amount, 658 | pnt_id=cte.col.pnt_id, 659 | region_name=cte.col.region_name, 660 | ) 661 | .filter(region_id="earth") 662 | .order_by("amount") 663 | ) 664 | print(qs.query) 665 | self.assertEqual(list(qs), [ 666 | {'amt': 30, 'region_name': 'earth', 'pnt_id': 'sun'}, 667 | {'amt': 31, 'region_name': 'earth', 'pnt_id': 'sun'}, 668 | {'amt': 32, 'region_name': 'earth', 'pnt_id': 'sun'}, 669 | {'amt': 33, 'region_name': 'earth', 'pnt_id': 'sun'}, 670 | ]) 671 | -------------------------------------------------------------------------------- /tests/test_django.py: -------------------------------------------------------------------------------- 1 | from unittest import SkipTest 2 | 3 | import django 4 | from django.db import OperationalError, ProgrammingError 5 | from django.db.models import Window 6 | from django.db.models.functions import Rank 7 | from django.test import TestCase 8 | 9 | from django_cte import CTE, with_cte 10 | 11 | from .models import Order, Region 12 | 13 | 14 | class WindowFunctions(TestCase): 15 | 16 | def test_heterogeneous_filter_in_cte(self): 17 | if django.VERSION < (4, 2): 18 | raise SkipTest("feature added in Django 4.2") 19 | cte = CTE( 20 | Order.objects.annotate( 21 | region_amount_rank=Window( 22 | Rank(), partition_by="region_id", order_by="-amount" 23 | ), 24 | ) 25 | .order_by("region_id") 26 | .values("region_id", "region_amount_rank") 27 | .filter(region_amount_rank=1, region_id__in=["sun", "moon"]) 28 | ) 29 | qs = with_cte(cte, select=cte.join(Region, name=cte.col.region_id)) 30 | print(qs.query) 31 | # ProgrammingError: column cte.region_id does not exist 32 | # WITH RECURSIVE "cte" AS (SELECT * FROM ( 33 | # SELECT "orders"."region_id" AS "col1", ... 34 | # "region" INNER JOIN "cte" ON "region"."name" = ("cte"."region_id") 35 | try: 36 | self.assertEqual({r.name for r in qs}, {"moon", "sun"}) 37 | except (OperationalError, ProgrammingError) as err: 38 | if "cte.region_id" in str(err): 39 | raise SkipTest( 40 | "window function auto-aliasing breaks CTE " 41 | "column references" 42 | ) 43 | raise 44 | if django.VERSION < (5, 2): 45 | assert 0, "unexpected pass" 46 | -------------------------------------------------------------------------------- /tests/test_manager.py: -------------------------------------------------------------------------------- 1 | from django.db.models.expressions import F 2 | from django.test import TestCase 3 | 4 | from django_cte import CTE, with_cte 5 | 6 | from .models import ( 7 | OrderFromLT40, 8 | OrderCustomManagerNQuery, 9 | LT40QuerySet, 10 | ) 11 | 12 | 13 | class TestCTE(TestCase): 14 | 15 | def test_cte_queryset_with_from_queryset(self): 16 | self.assertEqual(type(OrderFromLT40.objects.all()), LT40QuerySet) 17 | 18 | cte = CTE( 19 | OrderFromLT40.objects 20 | .annotate(region_parent=F("region__parent_id")) 21 | .filter(region__parent_id="sun") 22 | ) 23 | orders = with_cte( 24 | cte, 25 | select=cte.queryset() 26 | .lt40() # custom queryset method 27 | .order_by("region_id", "amount") 28 | ) 29 | print(orders.query) 30 | 31 | data = [(x.region_id, x.amount, x.region_parent) for x in orders] 32 | self.assertEqual(data, [ 33 | ("earth", 30, "sun"), 34 | ("earth", 31, "sun"), 35 | ("earth", 32, "sun"), 36 | ("earth", 33, "sun"), 37 | ('mercury', 10, 'sun'), 38 | ('mercury', 11, 'sun'), 39 | ('mercury', 12, 'sun'), 40 | ('venus', 20, 'sun'), 41 | ('venus', 21, 'sun'), 42 | ('venus', 22, 'sun'), 43 | ('venus', 23, 'sun'), 44 | ]) 45 | 46 | def test_cte_queryset_with_custom_queryset(self): 47 | cte = CTE( 48 | OrderCustomManagerNQuery.objects 49 | .annotate(region_parent=F("region__parent_id")) 50 | .filter(region__parent_id="sun") 51 | ) 52 | orders = with_cte( 53 | cte, 54 | select=cte.queryset() 55 | .lt25() # custom queryset method 56 | .order_by("region_id", "amount") 57 | ) 58 | print(orders.query) 59 | 60 | data = [(x.region_id, x.amount, x.region_parent) for x in orders] 61 | self.assertEqual(data, [ 62 | ('mercury', 10, 'sun'), 63 | ('mercury', 11, 'sun'), 64 | ('mercury', 12, 'sun'), 65 | ('venus', 20, 'sun'), 66 | ('venus', 21, 'sun'), 67 | ('venus', 22, 'sun'), 68 | ('venus', 23, 'sun'), 69 | ]) 70 | 71 | def test_cte_queryset_with_deferred_loading(self): 72 | cte = CTE( 73 | OrderCustomManagerNQuery.objects.order_by("id").only("id")[:1] 74 | ) 75 | orders = with_cte(cte, select=cte) 76 | print(orders.query) 77 | 78 | self.assertEqual([x.id for x in orders], [1]) 79 | -------------------------------------------------------------------------------- /tests/test_raw.py: -------------------------------------------------------------------------------- 1 | from django.db.models import IntegerField, TextField 2 | from django.test import TestCase 3 | 4 | from django_cte import CTE, with_cte 5 | from django_cte.raw import raw_cte_sql 6 | 7 | from .models import Region 8 | 9 | int_field = IntegerField() 10 | text_field = TextField() 11 | 12 | 13 | class TestRawCTE(TestCase): 14 | 15 | def test_raw_cte_sql(self): 16 | cte = CTE(raw_cte_sql( 17 | """ 18 | SELECT region_id, AVG(amount) AS avg_order 19 | FROM orders 20 | WHERE region_id = %s 21 | GROUP BY region_id 22 | """, 23 | ["moon"], 24 | {"region_id": text_field, "avg_order": int_field}, 25 | )) 26 | moon_avg = with_cte( 27 | cte, select=cte.join(Region, name=cte.col.region_id) 28 | ).annotate(avg_order=cte.col.avg_order) 29 | print(moon_avg.query) 30 | 31 | data = [(r.name, r.parent.name, r.avg_order) for r in moon_avg] 32 | self.assertEqual(data, [('moon', 'earth', 2)]) 33 | 34 | def test_raw_cte_sql_name_escape(self): 35 | cte = CTE( 36 | raw_cte_sql( 37 | """ 38 | SELECT region_id, AVG(amount) AS avg_order 39 | FROM orders 40 | WHERE region_id = %s 41 | GROUP BY region_id 42 | """, 43 | ["moon"], 44 | {"region_id": text_field, "avg_order": int_field}, 45 | ), 46 | name="mixedCaseCTEName" 47 | ) 48 | moon_avg = with_cte( 49 | cte, select=cte.join(Region, name=cte.col.region_id) 50 | ).annotate(avg_order=cte.col.avg_order) 51 | self.assertTrue( 52 | str(moon_avg.query).startswith( 53 | 'WITH RECURSIVE "mixedCaseCTEName"') 54 | ) 55 | -------------------------------------------------------------------------------- /tests/test_recursive.py: -------------------------------------------------------------------------------- 1 | import pickle 2 | from unittest import SkipTest 3 | 4 | from django.db.models import IntegerField, TextField 5 | from django.db.models.expressions import ( 6 | Case, 7 | Exists, 8 | ExpressionWrapper, 9 | F, 10 | OuterRef, 11 | Q, 12 | Value, 13 | When, 14 | ) 15 | from django.db.models.functions import Concat 16 | from django.db.utils import DatabaseError 17 | from django.test import TestCase 18 | 19 | from django_cte import CTE, with_cte 20 | 21 | from .models import KeyPair, Region 22 | 23 | int_field = IntegerField() 24 | text_field = TextField() 25 | 26 | 27 | class TestRecursiveCTE(TestCase): 28 | 29 | def test_recursive_cte_query(self): 30 | def make_regions_cte(cte): 31 | return Region.objects.filter( 32 | # non-recursive: get root nodes 33 | parent__isnull=True 34 | ).values( 35 | "name", 36 | path=F("name"), 37 | depth=Value(0, output_field=int_field), 38 | ).union( 39 | # recursive union: get descendants 40 | cte.join(Region, parent=cte.col.name).values( 41 | "name", 42 | path=Concat( 43 | cte.col.path, Value(" / "), F("name"), 44 | output_field=text_field, 45 | ), 46 | depth=cte.col.depth + Value(1, output_field=int_field), 47 | ), 48 | all=True, 49 | ) 50 | 51 | cte = CTE.recursive(make_regions_cte) 52 | 53 | regions = with_cte( 54 | cte, 55 | select=cte.join(Region, name=cte.col.name) 56 | .annotate( 57 | path=cte.col.path, 58 | depth=cte.col.depth, 59 | ) 60 | .filter(depth=2) 61 | .order_by("path") 62 | ) 63 | print(regions.query) 64 | 65 | data = [(r.name, r.path, r.depth) for r in regions] 66 | self.assertEqual(data, [ 67 | ('moon', 'sun / earth / moon', 2), 68 | ('deimos', 'sun / mars / deimos', 2), 69 | ('phobos', 'sun / mars / phobos', 2), 70 | ]) 71 | 72 | def test_recursive_cte_reference_in_condition(self): 73 | def make_regions_cte(cte): 74 | return Region.objects.filter( 75 | parent__isnull=True 76 | ).values( 77 | "name", 78 | path=F("name"), 79 | depth=Value(0, output_field=int_field), 80 | is_planet=Value(0, output_field=int_field), 81 | ).union( 82 | cte.join( 83 | Region, parent=cte.col.name 84 | ).annotate( 85 | # annotations for filter and CASE/WHEN conditions 86 | parent_name=ExpressionWrapper( 87 | cte.col.name, 88 | output_field=text_field, 89 | ), 90 | parent_depth=ExpressionWrapper( 91 | cte.col.depth, 92 | output_field=int_field, 93 | ), 94 | ).filter( 95 | ~Q(parent_name="mars"), 96 | ).values( 97 | "name", 98 | path=Concat( 99 | cte.col.path, Value("\x01"), F("name"), 100 | output_field=text_field, 101 | ), 102 | depth=cte.col.depth + Value(1, output_field=int_field), 103 | is_planet=Case( 104 | When(parent_depth=0, then=Value(1)), 105 | default=Value(0), 106 | output_field=int_field, 107 | ), 108 | ), 109 | all=True, 110 | ) 111 | cte = CTE.recursive(make_regions_cte) 112 | regions = with_cte( 113 | cte, select=cte.join(Region, name=cte.col.name) 114 | ).annotate( 115 | path=cte.col.path, 116 | depth=cte.col.depth, 117 | is_planet=cte.col.is_planet, 118 | ).order_by("path") 119 | 120 | data = [(r.path.split("\x01"), r.is_planet) for r in regions] 121 | print(data) 122 | self.assertEqual(data, [ 123 | (["bernard's star"], 0), 124 | (['proxima centauri'], 0), 125 | (['proxima centauri', 'proxima centauri b'], 1), 126 | (['sun'], 0), 127 | (['sun', 'earth'], 1), 128 | (['sun', 'earth', 'moon'], 0), 129 | (['sun', 'mars'], 1), # mars moons excluded: parent_name != 'mars' 130 | (['sun', 'mercury'], 1), 131 | (['sun', 'venus'], 1), 132 | ]) 133 | 134 | def test_recursive_cte_with_empty_union_part(self): 135 | def make_regions_cte(cte): 136 | return Region.objects.none().union( 137 | cte.join(Region, parent=cte.col.name), 138 | all=True, 139 | ) 140 | cte = CTE.recursive(make_regions_cte) 141 | regions = with_cte(cte, select=cte.join(Region, name=cte.col.name)) 142 | 143 | print(regions.query) 144 | try: 145 | self.assertEqual(regions.count(), 0) 146 | except DatabaseError: 147 | raise SkipTest( 148 | "Expected failure: QuerySet omits `EmptyQuerySet` from " 149 | "UNION queries resulting in invalid CTE SQL" 150 | ) 151 | 152 | # -- recursive query "cte" does not have the form 153 | # -- non-recursive-term UNION [ALL] recursive-term 154 | # WITH RECURSIVE cte AS ( 155 | # SELECT "tests_region"."name", "tests_region"."parent_id" 156 | # FROM "tests_region", "cte" 157 | # WHERE "tests_region"."parent_id" = ("cte"."name") 158 | # ) 159 | # SELECT COUNT(*) 160 | # FROM "tests_region", "cte" 161 | # WHERE "tests_region"."name" = ("cte"."name") 162 | 163 | def test_circular_ref_error(self): 164 | def make_bad_cte(cte): 165 | # NOTE: not a valid recursive CTE query 166 | return cte.join(Region, parent=cte.col.name).values( 167 | depth=cte.col.depth + 1, 168 | ) 169 | cte = CTE.recursive(make_bad_cte) 170 | regions = with_cte(cte, select=cte.join(Region, name=cte.col.name)) 171 | with self.assertRaises(ValueError) as context: 172 | print(regions.query) 173 | self.assertIn("Circular reference:", str(context.exception)) 174 | 175 | def test_attname_should_not_mask_col_name(self): 176 | def make_regions_cte(cte): 177 | return Region.objects.filter( 178 | name="moon" 179 | ).values( 180 | "name", 181 | "parent_id", 182 | ).union( 183 | cte.join(Region, name=cte.col.parent_id).values( 184 | "name", 185 | "parent_id", 186 | ), 187 | all=True, 188 | ) 189 | cte = CTE.recursive(make_regions_cte) 190 | regions = with_cte( 191 | cte, 192 | select=Region.objects.annotate(_ex=Exists( 193 | cte.queryset() 194 | .values(value=Value("1", output_field=int_field)) 195 | .filter(name=OuterRef("name")) 196 | )) 197 | .filter(_ex=True) 198 | .order_by("name") 199 | ) 200 | print(regions.query) 201 | 202 | data = [r.name for r in regions] 203 | self.assertEqual(data, ['earth', 'moon', 'sun']) 204 | 205 | def test_pickle_recursive_cte_queryset(self): 206 | def make_regions_cte(cte): 207 | return Region.objects.filter( 208 | parent__isnull=True 209 | ).annotate( 210 | depth=Value(0, output_field=int_field), 211 | ).union( 212 | cte.join(Region, parent=cte.col.name).annotate( 213 | depth=cte.col.depth + Value(1, output_field=int_field), 214 | ), 215 | all=True, 216 | ) 217 | cte = CTE.recursive(make_regions_cte) 218 | regions = with_cte(cte, select=cte).filter(depth=2).order_by("name") 219 | 220 | pickled_qs = pickle.loads(pickle.dumps(regions)) 221 | 222 | data = [(r.name, r.depth) for r in pickled_qs] 223 | self.assertEqual(data, [(r.name, r.depth) for r in regions]) 224 | self.assertEqual(data, [('deimos', 2), ('moon', 2), ('phobos', 2)]) 225 | 226 | def test_alias_change_in_annotation(self): 227 | def make_regions_cte(cte): 228 | return Region.objects.filter( 229 | parent__name="sun", 230 | ).annotate( 231 | value=F('name'), 232 | ).union( 233 | cte.join( 234 | Region.objects.annotate(value=F('name')), 235 | parent_id=cte.col.name, 236 | ), 237 | all=True, 238 | ) 239 | cte = CTE.recursive(make_regions_cte) 240 | query = with_cte(cte, select=cte) 241 | 242 | exclude_leaves = CTE(cte.queryset().filter( 243 | parent__name='sun', 244 | ).annotate( 245 | value=Concat(F('name'), F('name')) 246 | ), name='value_cte') 247 | 248 | query = with_cte(exclude_leaves, select=query.annotate( 249 | _exclude_leaves=Exists( 250 | exclude_leaves.queryset().filter( 251 | name=OuterRef("name"), 252 | value=OuterRef("value"), 253 | ) 254 | ) 255 | ).filter(_exclude_leaves=True)) 256 | print(query.query) 257 | 258 | # Nothing should be returned. 259 | self.assertFalse(query) 260 | 261 | def test_alias_as_subquery(self): 262 | # This test covers CTEColumnRef.relabeled_clone 263 | def make_regions_cte(cte): 264 | return KeyPair.objects.filter( 265 | parent__key="level 1", 266 | ).annotate( 267 | rank=F('value'), 268 | ).union( 269 | cte.join( 270 | KeyPair.objects.order_by(), 271 | parent_id=cte.col.id, 272 | ).annotate( 273 | rank=F('value'), 274 | ), 275 | all=True, 276 | ) 277 | cte = CTE.recursive(make_regions_cte) 278 | children = with_cte(cte, select=cte) 279 | 280 | xdups = CTE(cte.queryset().filter( 281 | parent__key="level 1", 282 | ).annotate( 283 | rank=F('value') 284 | ).values('id', 'rank'), name='xdups') 285 | 286 | children = with_cte(xdups, select=children.annotate( 287 | _exclude=Exists( 288 | ( 289 | xdups.queryset().filter( 290 | id=OuterRef("id"), 291 | rank=OuterRef("rank"), 292 | ) 293 | ) 294 | ) 295 | ).filter(_exclude=True)) 296 | 297 | print(children.query) 298 | query = KeyPair.objects.filter(parent__in=children) 299 | print(query.query) 300 | print(children.query) 301 | self.assertEqual(query.get().key, 'level 3') 302 | # Tests the case in which children's query was modified since it was 303 | # used in a subquery to define `query` above. 304 | self.assertEqual( 305 | list(c.key for c in children), 306 | ['level 2', 'level 2'] 307 | ) 308 | 309 | def test_materialized(self): 310 | # This test covers MATERIALIZED option in SQL query 311 | def make_regions_cte(cte): 312 | return KeyPair.objects.all() 313 | cte = CTE.recursive(make_regions_cte, materialized=True) 314 | 315 | query = with_cte(cte, select=KeyPair) 316 | print(query.query) 317 | self.assertTrue( 318 | str(query.query).startswith('WITH RECURSIVE "cte" AS MATERIALIZED') 319 | ) 320 | 321 | def test_recursive_self_queryset(self): 322 | def make_regions_cte(cte): 323 | return Region.objects.filter( 324 | pk="earth" 325 | ).values("pk").union( 326 | cte.join(Region, parent=cte.col.pk).values("pk") 327 | ) 328 | cte = CTE.recursive(make_regions_cte) 329 | queryset = with_cte(cte, select=cte).order_by("pk") 330 | print(queryset.query) 331 | self.assertEqual(list(queryset), [ 332 | {'pk': 'earth'}, 333 | {'pk': 'moon'}, 334 | ]) 335 | -------------------------------------------------------------------------------- /tests/test_v1/__init__.py: -------------------------------------------------------------------------------- 1 | from unmagic import fixture 2 | 3 | from .. import ignore_v1_warnings 4 | 5 | 6 | @fixture(autouse=__file__) 7 | def ignore_v1_deprecations(): 8 | with ignore_v1_warnings(): 9 | yield 10 | 11 | 12 | @fixture(autouse=__file__, scope="class") 13 | def ignore_v1_deprecations_in_class_setup(): 14 | with ignore_v1_warnings(): 15 | yield 16 | 17 | 18 | with ignore_v1_warnings(): 19 | from . import models # noqa: F401 20 | -------------------------------------------------------------------------------- /tests/test_v1/models.py: -------------------------------------------------------------------------------- 1 | from django.db.models import Manager 2 | 3 | from django_cte import CTEManager, CTEQuerySet 4 | 5 | from ..models import ( 6 | KeyPair as V2KeyPair, 7 | Order as V2Order, 8 | Region as V2Region, 9 | User, # noqa: F401 10 | ) 11 | 12 | 13 | class LT40QuerySet(CTEQuerySet): 14 | 15 | def lt40(self): 16 | return self.filter(amount__lt=40) 17 | 18 | 19 | class LT30QuerySet(CTEQuerySet): 20 | 21 | def lt30(self): 22 | return self.filter(amount__lt=30) 23 | 24 | 25 | class LT25QuerySet(CTEQuerySet): 26 | 27 | def lt25(self): 28 | return self.filter(amount__lt=25) 29 | 30 | 31 | class LTManager(CTEManager): 32 | pass 33 | 34 | 35 | class V1Region(V2Region): 36 | objects = CTEManager() 37 | 38 | class Meta: 39 | proxy = True 40 | 41 | 42 | Region = V1Region 43 | 44 | 45 | class V1Order(V2Order): 46 | objects = CTEManager() 47 | 48 | class Meta: 49 | proxy = True 50 | 51 | 52 | Order = V1Order 53 | 54 | 55 | class V1OrderFromLT40(Order): 56 | class Meta: 57 | proxy = True 58 | objects = CTEManager.from_queryset(LT40QuerySet)() 59 | 60 | 61 | class V1OrderLT40AsManager(Order): 62 | class Meta: 63 | proxy = True 64 | objects = LT40QuerySet.as_manager() 65 | 66 | 67 | class V1OrderCustomManagerNQuery(Order): 68 | class Meta: 69 | proxy = True 70 | objects = LTManager.from_queryset(LT25QuerySet)() 71 | 72 | 73 | class V1OrderCustomManager(Order): 74 | class Meta: 75 | proxy = True 76 | objects = LTManager() 77 | 78 | 79 | class V1OrderPlainManager(Order): 80 | class Meta: 81 | proxy = True 82 | objects = Manager() 83 | 84 | 85 | class V1KeyPair(V2KeyPair): 86 | objects = CTEManager() 87 | 88 | class Meta: 89 | proxy = True 90 | 91 | 92 | KeyPair = V1KeyPair 93 | OrderCustomManager = V1OrderCustomManager 94 | OrderCustomManagerNQuery = V1OrderCustomManagerNQuery 95 | OrderFromLT40 = V1OrderFromLT40 96 | OrderLT40AsManager = V1OrderLT40AsManager 97 | OrderPlainManager = V1OrderPlainManager 98 | -------------------------------------------------------------------------------- /tests/test_v1/test_combinators.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.db.models import Value 3 | from django.db.models.aggregates import Sum 4 | from django.test import TestCase 5 | 6 | from django_cte import With 7 | 8 | from .models import Order, OrderPlainManager 9 | 10 | 11 | class TestCTECombinators(TestCase): 12 | 13 | def test_cte_union_query(self): 14 | one = With( 15 | Order.objects 16 | .values("region_id") 17 | .annotate(total=Sum("amount")), 18 | name="one" 19 | ) 20 | two = With( 21 | Order.objects 22 | .values("region_id") 23 | .annotate(total=Sum("amount") * 2), 24 | name="two" 25 | ) 26 | 27 | earths = ( 28 | one.join( 29 | Order.objects.filter(region_id="earth"), 30 | region=one.col.region_id 31 | ) 32 | .with_cte(one) 33 | .annotate(region_total=one.col.total) 34 | .values_list("amount", "region_id", "region_total") 35 | ) 36 | mars = ( 37 | two.join( 38 | Order.objects.filter(region_id="mars"), 39 | region=two.col.region_id 40 | ) 41 | .with_cte(two) 42 | .annotate(region_total=two.col.total) 43 | .values_list("amount", "region_id", "region_total") 44 | ) 45 | combined = earths.union(mars, all=True) 46 | print(combined.query) 47 | 48 | self.assertEqual(sorted(combined), [ 49 | (30, 'earth', 126), 50 | (31, 'earth', 126), 51 | (32, 'earth', 126), 52 | (33, 'earth', 126), 53 | (40, 'mars', 246), 54 | (41, 'mars', 246), 55 | (42, 'mars', 246), 56 | ]) 57 | 58 | # queries used in union should still work on their own 59 | print(earths.query) 60 | self.assertEqual(sorted(earths),[ 61 | (30, 'earth', 126), 62 | (31, 'earth', 126), 63 | (32, 'earth', 126), 64 | (33, 'earth', 126), 65 | ]) 66 | print(mars.query) 67 | self.assertEqual(sorted(mars),[ 68 | (40, 'mars', 246), 69 | (41, 'mars', 246), 70 | (42, 'mars', 246), 71 | ]) 72 | 73 | def test_cte_union_with_non_cte_query(self): 74 | one = With( 75 | Order.objects 76 | .values("region_id") 77 | .annotate(total=Sum("amount")), 78 | ) 79 | 80 | earths = ( 81 | one.join( 82 | Order.objects.filter(region_id="earth"), 83 | region=one.col.region_id 84 | ) 85 | .with_cte(one) 86 | .annotate(region_total=one.col.total) 87 | ) 88 | plain_mars = ( 89 | OrderPlainManager.objects.filter(region_id="mars") 90 | .annotate(region_total=Value(0)) 91 | ) 92 | # Note: this does not work in the opposite order. A CTE query 93 | # must come first to invoke custom CTE combinator logic. 94 | combined = earths.union(plain_mars, all=True) \ 95 | .values_list("amount", "region_id", "region_total") 96 | print(combined.query) 97 | 98 | self.assertEqual(sorted(combined), [ 99 | (30, 'earth', 126), 100 | (31, 'earth', 126), 101 | (32, 'earth', 126), 102 | (33, 'earth', 126), 103 | (40, 'mars', 0), 104 | (41, 'mars', 0), 105 | (42, 'mars', 0), 106 | ]) 107 | 108 | def test_cte_union_with_duplicate_names(self): 109 | cte_sun = With( 110 | Order.objects 111 | .filter(region__parent="sun") 112 | .values("region_id") 113 | .annotate(total=Sum("amount")), 114 | ) 115 | cte_proxima = With( 116 | Order.objects 117 | .filter(region__parent="proxima centauri") 118 | .values("region_id") 119 | .annotate(total=2 * Sum("amount")), 120 | ) 121 | 122 | orders_sun = ( 123 | cte_sun.join(Order, region=cte_sun.col.region_id) 124 | .with_cte(cte_sun) 125 | .annotate(region_total=cte_sun.col.total) 126 | ) 127 | orders_proxima = ( 128 | cte_proxima.join(Order, region=cte_proxima.col.region_id) 129 | .with_cte(cte_proxima) 130 | .annotate(region_total=cte_proxima.col.total) 131 | ) 132 | 133 | msg = "Found two or more CTEs named 'cte'" 134 | with pytest.raises(ValueError, match=msg): 135 | orders_sun.union(orders_proxima) 136 | 137 | def test_cte_union_of_same_cte(self): 138 | cte = With( 139 | Order.objects 140 | .filter(region__parent="sun") 141 | .values("region_id") 142 | .annotate(total=Sum("amount")), 143 | ) 144 | 145 | orders_big = ( 146 | cte.join(Order, region=cte.col.region_id) 147 | .with_cte(cte) 148 | .annotate(region_total=3 * cte.col.total) 149 | ) 150 | orders_small = ( 151 | cte.join(Order, region=cte.col.region_id) 152 | .with_cte(cte) 153 | .annotate(region_total=cte.col.total) 154 | ) 155 | 156 | orders = orders_big.union(orders_small) \ 157 | .values_list("amount", "region_id", "region_total") 158 | print(orders.query) 159 | 160 | self.assertEqual(sorted(orders), [ 161 | (10, 'mercury', 33), 162 | (10, 'mercury', 99), 163 | (11, 'mercury', 33), 164 | (11, 'mercury', 99), 165 | (12, 'mercury', 33), 166 | (12, 'mercury', 99), 167 | (20, 'venus', 86), 168 | (20, 'venus', 258), 169 | (21, 'venus', 86), 170 | (21, 'venus', 258), 171 | (22, 'venus', 86), 172 | (22, 'venus', 258), 173 | (23, 'venus', 86), 174 | (23, 'venus', 258), 175 | (30, 'earth', 126), 176 | (30, 'earth', 378), 177 | (31, 'earth', 126), 178 | (31, 'earth', 378), 179 | (32, 'earth', 126), 180 | (32, 'earth', 378), 181 | (33, 'earth', 126), 182 | (33, 'earth', 378), 183 | (40, 'mars', 123), 184 | (40, 'mars', 369), 185 | (41, 'mars', 123), 186 | (41, 'mars', 369), 187 | (42, 'mars', 123), 188 | (42, 'mars', 369) 189 | ]) 190 | 191 | def test_cte_intersection(self): 192 | cte_big = With( 193 | Order.objects 194 | .values("region_id") 195 | .annotate(total=Sum("amount")), 196 | name='big' 197 | ) 198 | cte_small = With( 199 | Order.objects 200 | .values("region_id") 201 | .annotate(total=Sum("amount")), 202 | name='small' 203 | ) 204 | orders_big = ( 205 | cte_big.join(Order, region=cte_big.col.region_id) 206 | .with_cte(cte_big) 207 | .annotate(region_total=cte_big.col.total) 208 | .filter(region_total__gte=86) 209 | ) 210 | orders_small = ( 211 | cte_small.join(Order, region=cte_small.col.region_id) 212 | .with_cte(cte_small) 213 | .annotate(region_total=cte_small.col.total) 214 | .filter(region_total__lte=123) 215 | ) 216 | 217 | orders = orders_small.intersection(orders_big) \ 218 | .values_list("amount", "region_id", "region_total") 219 | print(orders.query) 220 | 221 | self.assertEqual(sorted(orders), [ 222 | (20, 'venus', 86), 223 | (21, 'venus', 86), 224 | (22, 'venus', 86), 225 | (23, 'venus', 86), 226 | (40, 'mars', 123), 227 | (41, 'mars', 123), 228 | (42, 'mars', 123), 229 | ]) 230 | 231 | def test_cte_difference(self): 232 | cte_big = With( 233 | Order.objects 234 | .values("region_id") 235 | .annotate(total=Sum("amount")), 236 | name='big' 237 | ) 238 | cte_small = With( 239 | Order.objects 240 | .values("region_id") 241 | .annotate(total=Sum("amount")), 242 | name='small' 243 | ) 244 | orders_big = ( 245 | cte_big.join(Order, region=cte_big.col.region_id) 246 | .with_cte(cte_big) 247 | .annotate(region_total=cte_big.col.total) 248 | .filter(region_total__gte=86) 249 | ) 250 | orders_small = ( 251 | cte_small.join(Order, region=cte_small.col.region_id) 252 | .with_cte(cte_small) 253 | .annotate(region_total=cte_small.col.total) 254 | .filter(region_total__lte=123) 255 | ) 256 | 257 | orders = orders_small.difference(orders_big) \ 258 | .values_list("amount", "region_id", "region_total") 259 | print(orders.query) 260 | 261 | self.assertEqual(sorted(orders), [ 262 | (1, 'moon', 6), 263 | (2, 'moon', 6), 264 | (3, 'moon', 6), 265 | (10, 'mercury', 33), 266 | (10, 'proxima centauri b', 33), 267 | (11, 'mercury', 33), 268 | (11, 'proxima centauri b', 33), 269 | (12, 'mercury', 33), 270 | (12, 'proxima centauri b', 33), 271 | ]) 272 | -------------------------------------------------------------------------------- /tests/test_v1/test_cte.py: -------------------------------------------------------------------------------- 1 | from unittest import SkipTest 2 | 3 | from django.db.models import IntegerField, TextField 4 | from django.db.models.aggregates import Count, Max, Min, Sum 5 | from django.db.models.expressions import ( 6 | Exists, ExpressionWrapper, F, OuterRef, Subquery, 7 | ) 8 | from django.db.models.sql.constants import LOUTER 9 | from django.test import TestCase 10 | 11 | from django_cte import With 12 | from django_cte import CTEManager 13 | 14 | from .models import Order, Region, User 15 | 16 | int_field = IntegerField() 17 | text_field = TextField() 18 | 19 | 20 | class TestCTE(TestCase): 21 | 22 | def test_simple_cte_query(self): 23 | cte = With( 24 | Order.objects 25 | .values("region_id") 26 | .annotate(total=Sum("amount")) 27 | ) 28 | 29 | orders = ( 30 | # FROM orders INNER JOIN cte ON orders.region_id = cte.region_id 31 | cte.join(Order, region=cte.col.region_id) 32 | 33 | # Add `WITH ...` before `SELECT ... FROM orders ...` 34 | .with_cte(cte) 35 | 36 | # Annotate each Order with a "region_total" 37 | .annotate(region_total=cte.col.total) 38 | ) 39 | print(orders.query) 40 | 41 | data = sorted((o.amount, o.region_id, o.region_total) for o in orders) 42 | self.assertEqual(data, [ 43 | (1, 'moon', 6), 44 | (2, 'moon', 6), 45 | (3, 'moon', 6), 46 | (10, 'mercury', 33), 47 | (10, 'proxima centauri b', 33), 48 | (11, 'mercury', 33), 49 | (11, 'proxima centauri b', 33), 50 | (12, 'mercury', 33), 51 | (12, 'proxima centauri b', 33), 52 | (20, 'venus', 86), 53 | (21, 'venus', 86), 54 | (22, 'venus', 86), 55 | (23, 'venus', 86), 56 | (30, 'earth', 126), 57 | (31, 'earth', 126), 58 | (32, 'earth', 126), 59 | (33, 'earth', 126), 60 | (40, 'mars', 123), 61 | (41, 'mars', 123), 62 | (42, 'mars', 123), 63 | (1000, 'sun', 1000), 64 | (2000, 'proxima centauri', 2000), 65 | ]) 66 | 67 | def test_cte_name_escape(self): 68 | totals = With( 69 | Order.objects 70 | .filter(region__parent="sun") 71 | .values("region_id") 72 | .annotate(total=Sum("amount")), 73 | name="mixedCaseCTEName" 74 | ) 75 | orders = ( 76 | totals 77 | .join(Order, region=totals.col.region_id) 78 | .with_cte(totals) 79 | .annotate(region_total=totals.col.total) 80 | .order_by("amount") 81 | ) 82 | self.assertTrue( 83 | str(orders.query).startswith('WITH RECURSIVE "mixedCaseCTEName"')) 84 | 85 | def test_cte_queryset(self): 86 | sub_totals = With( 87 | Order.objects 88 | .values(region_parent=F("region__parent_id")) 89 | .annotate(total=Sum("amount")), 90 | ) 91 | regions = ( 92 | Region.objects.all() 93 | .with_cte(sub_totals) 94 | .annotate( 95 | child_regions_total=Subquery( 96 | sub_totals.queryset() 97 | .filter(region_parent=OuterRef("name")) 98 | .values("total"), 99 | ), 100 | ) 101 | .order_by("name") 102 | ) 103 | print(regions.query) 104 | 105 | data = [(r.name, r.child_regions_total) for r in regions] 106 | self.assertEqual(data, [ 107 | ("bernard's star", None), 108 | ('deimos', None), 109 | ('earth', 6), 110 | ('mars', None), 111 | ('mercury', None), 112 | ('moon', None), 113 | ('phobos', None), 114 | ('proxima centauri', 33), 115 | ('proxima centauri b', None), 116 | ('sun', 368), 117 | ('venus', None) 118 | ]) 119 | 120 | def test_cte_queryset_with_model_result(self): 121 | cte = With( 122 | Order.objects 123 | .annotate(region_parent=F("region__parent_id")), 124 | ) 125 | orders = cte.queryset().with_cte(cte) 126 | print(orders.query) 127 | 128 | data = sorted( 129 | (x.region_id, x.amount, x.region_parent) for x in orders)[:5] 130 | self.assertEqual(data, [ 131 | ("earth", 30, "sun"), 132 | ("earth", 31, "sun"), 133 | ("earth", 32, "sun"), 134 | ("earth", 33, "sun"), 135 | ("mars", 40, "sun"), 136 | ]) 137 | self.assertTrue( 138 | all(isinstance(x, Order) for x in orders), 139 | repr([x for x in orders]), 140 | ) 141 | 142 | def test_cte_queryset_with_join(self): 143 | cte = With( 144 | Order.objects 145 | .annotate(region_parent=F("region__parent_id")), 146 | ) 147 | orders = ( 148 | cte.queryset() 149 | .with_cte(cte) 150 | .annotate(parent=F("region__parent_id")) 151 | .order_by("region_id", "amount") 152 | ) 153 | print(orders.query) 154 | 155 | data = [(x.region_id, x.region_parent, x.parent) for x in orders][:5] 156 | self.assertEqual(data, [ 157 | ("earth", "sun", "sun"), 158 | ("earth", "sun", "sun"), 159 | ("earth", "sun", "sun"), 160 | ("earth", "sun", "sun"), 161 | ("mars", "sun", "sun"), 162 | ]) 163 | 164 | def test_cte_queryset_with_values_result(self): 165 | cte = With( 166 | Order.objects 167 | .values( 168 | "region_id", 169 | region_parent=F("region__parent_id"), 170 | ) 171 | .distinct() 172 | ) 173 | values = ( 174 | cte.queryset() 175 | .with_cte(cte) 176 | .filter(region_parent__isnull=False) 177 | ) 178 | print(values.query) 179 | 180 | def key(item): 181 | return item["region_parent"], item["region_id"] 182 | 183 | data = sorted(values, key=key)[:5] 184 | self.assertEqual(data, [ 185 | {'region_id': 'moon', 'region_parent': 'earth'}, 186 | { 187 | 'region_id': 'proxima centauri b', 188 | 'region_parent': 'proxima centauri', 189 | }, 190 | {'region_id': 'earth', 'region_parent': 'sun'}, 191 | {'region_id': 'mars', 'region_parent': 'sun'}, 192 | {'region_id': 'mercury', 'region_parent': 'sun'}, 193 | ]) 194 | 195 | def test_named_simple_ctes(self): 196 | totals = With( 197 | Order.objects 198 | .filter(region__parent="sun") 199 | .values("region_id") 200 | .annotate(total=Sum("amount")), 201 | name="totals", 202 | ) 203 | region_count = With( 204 | Region.objects 205 | .filter(parent="sun") 206 | .values("parent_id") 207 | .annotate(num=Count("name")), 208 | name="region_count", 209 | ) 210 | orders = ( 211 | region_count.join( 212 | totals.join(Order, region=totals.col.region_id), 213 | region__parent=region_count.col.parent_id 214 | ) 215 | .with_cte(totals) 216 | .with_cte(region_count) 217 | .annotate(region_total=totals.col.total) 218 | .annotate(region_count=region_count.col.num) 219 | .order_by("amount") 220 | ) 221 | print(orders.query) 222 | 223 | data = [( 224 | o.amount, 225 | o.region_id, 226 | o.region_count, 227 | o.region_total, 228 | ) for o in orders] 229 | self.assertEqual(data, [ 230 | (10, 'mercury', 4, 33), 231 | (11, 'mercury', 4, 33), 232 | (12, 'mercury', 4, 33), 233 | (20, 'venus', 4, 86), 234 | (21, 'venus', 4, 86), 235 | (22, 'venus', 4, 86), 236 | (23, 'venus', 4, 86), 237 | (30, 'earth', 4, 126), 238 | (31, 'earth', 4, 126), 239 | (32, 'earth', 4, 126), 240 | (33, 'earth', 4, 126), 241 | (40, 'mars', 4, 123), 242 | (41, 'mars', 4, 123), 243 | (42, 'mars', 4, 123), 244 | ]) 245 | 246 | def test_named_ctes(self): 247 | def make_root_mapping(rootmap): 248 | return Region.objects.filter( 249 | parent__isnull=True 250 | ).values( 251 | "name", 252 | root=F("name"), 253 | ).union( 254 | rootmap.join(Region, parent=rootmap.col.name).values( 255 | "name", 256 | root=rootmap.col.root, 257 | ), 258 | all=True, 259 | ) 260 | rootmap = With.recursive(make_root_mapping, name="rootmap") 261 | 262 | totals = With( 263 | rootmap.join(Order, region_id=rootmap.col.name) 264 | .values( 265 | root=rootmap.col.root, 266 | ).annotate( 267 | orders_count=Count("id"), 268 | region_total=Sum("amount"), 269 | ), 270 | name="totals", 271 | ) 272 | 273 | root_regions = ( 274 | totals.join(Region, name=totals.col.root) 275 | .with_cte(rootmap) 276 | .with_cte(totals) 277 | .annotate( 278 | # count of orders in this region and all subregions 279 | orders_count=totals.col.orders_count, 280 | # sum of order amounts in this region and all subregions 281 | region_total=totals.col.region_total, 282 | ) 283 | ) 284 | print(root_regions.query) 285 | 286 | data = sorted( 287 | (r.name, r.orders_count, r.region_total) for r in root_regions 288 | ) 289 | self.assertEqual(data, [ 290 | ('proxima centauri', 4, 2033), 291 | ('sun', 18, 1374), 292 | ]) 293 | 294 | def test_materialized_option(self): 295 | totals = With( 296 | Order.objects 297 | .filter(region__parent="sun") 298 | .values("region_id") 299 | .annotate(total=Sum("amount")), 300 | materialized=True 301 | ) 302 | orders = ( 303 | totals 304 | .join(Order, region=totals.col.region_id) 305 | .with_cte(totals) 306 | .annotate(region_total=totals.col.total) 307 | .order_by("amount") 308 | ) 309 | self.assertTrue( 310 | str(orders.query).startswith( 311 | 'WITH RECURSIVE "cte" AS MATERIALIZED' 312 | ) 313 | ) 314 | 315 | def test_update_cte_query(self): 316 | cte = With( 317 | Order.objects 318 | .values(region_parent=F("region__parent_id")) 319 | .annotate(total=Sum("amount")) 320 | .filter(total__isnull=False) 321 | ) 322 | # not the most efficient query, but it exercises CTEUpdateQuery 323 | Order.objects.all().with_cte(cte).filter(region_id__in=Subquery( 324 | cte.queryset() 325 | .filter(region_parent=OuterRef("region_id")) 326 | .values("region_parent") 327 | )).update(amount=Subquery( 328 | cte.queryset() 329 | .filter(region_parent=OuterRef("region_id")) 330 | .values("total") 331 | )) 332 | 333 | data = set((o.region_id, o.amount) for o in Order.objects.filter( 334 | region_id__in=["earth", "sun", "proxima centauri", "mars"] 335 | )) 336 | self.assertEqual(data, { 337 | ('earth', 6), 338 | ('mars', 40), 339 | ('mars', 41), 340 | ('mars', 42), 341 | ('proxima centauri', 33), 342 | ('sun', 368), 343 | }) 344 | 345 | def test_update_with_subquery(self): 346 | # Test for issue: https://github.com/dimagi/django-cte/issues/9 347 | # Issue is not reproduces on sqlite3 use postgres to run. 348 | # To reproduce the problem it's required to have some join 349 | # in the select-query so the compiler will turn it into a subquery. 350 | # To add a join use a filter over field of related model 351 | orders = Order.objects.filter(region__parent_id='sun') 352 | orders.update(amount=0) 353 | data = {(order.region_id, order.amount) for order in orders} 354 | self.assertEqual(data, { 355 | ('mercury', 0), 356 | ('venus', 0), 357 | ('earth', 0), 358 | ('mars', 0), 359 | }) 360 | 361 | def test_delete_cte_query(self): 362 | raise SkipTest( 363 | "this test will not work until `QuerySet.delete` (Django method) " 364 | "calls `self.query.chain(sql.DeleteQuery)` instead of " 365 | "`sql.DeleteQuery(self.model)`" 366 | ) 367 | cte = With( 368 | Order.objects 369 | .values(region_parent=F("region__parent_id")) 370 | .annotate(total=Sum("amount")) 371 | .filter(total__isnull=False) 372 | ) 373 | Order.objects.all().with_cte(cte).annotate( 374 | cte_has_order=Exists( 375 | cte.queryset() 376 | .values("total") 377 | .filter(region_parent=OuterRef("region_id")) 378 | ) 379 | ).filter(cte_has_order=False).delete() 380 | 381 | data = [(o.region_id, o.amount) for o in Order.objects.all()] 382 | self.assertEqual(data, [ 383 | ('sun', 1000), 384 | ('earth', 30), 385 | ('earth', 31), 386 | ('earth', 32), 387 | ('earth', 33), 388 | ('proxima centauri', 2000), 389 | ]) 390 | 391 | def test_outerref_in_cte_query(self): 392 | # This query is meant to return the difference between min and max 393 | # order of each region, through a subquery 394 | min_and_max = With( 395 | Order.objects 396 | .filter(region=OuterRef("pk")) 397 | .values('region') # This is to force group by region_id 398 | .annotate( 399 | amount_min=Min("amount"), 400 | amount_max=Max("amount"), 401 | ) 402 | .values('amount_min', 'amount_max') 403 | ) 404 | regions = ( 405 | Region.objects 406 | .annotate( 407 | difference=Subquery( 408 | min_and_max.queryset().with_cte(min_and_max).annotate( 409 | difference=ExpressionWrapper( 410 | F('amount_max') - F('amount_min'), 411 | output_field=int_field, 412 | ), 413 | ).values('difference')[:1], 414 | output_field=IntegerField() 415 | ) 416 | ) 417 | .order_by("name") 418 | ) 419 | print(regions.query) 420 | 421 | data = [(r.name, r.difference) for r in regions] 422 | self.assertEqual(data, [ 423 | ("bernard's star", None), 424 | ('deimos', None), 425 | ('earth', 3), 426 | ('mars', 2), 427 | ('mercury', 2), 428 | ('moon', 2), 429 | ('phobos', None), 430 | ('proxima centauri', 0), 431 | ('proxima centauri b', 2), 432 | ('sun', 0), 433 | ('venus', 3) 434 | ]) 435 | 436 | def test_experimental_left_outer_join(self): 437 | totals = With( 438 | Order.objects 439 | .values("region_id") 440 | .annotate(total=Sum("amount")) 441 | .filter(total__gt=100) 442 | ) 443 | orders = ( 444 | totals 445 | .join(Order, region=totals.col.region_id, _join_type=LOUTER) 446 | .with_cte(totals) 447 | .annotate(region_total=totals.col.total) 448 | ) 449 | print(orders.query) 450 | self.assertIn("LEFT OUTER JOIN", str(orders.query)) 451 | self.assertNotIn("INNER JOIN", str(orders.query)) 452 | 453 | data = sorted((o.region_id, o.amount, o.region_total) for o in orders) 454 | self.assertEqual(data, [ 455 | ('earth', 30, 126), 456 | ('earth', 31, 126), 457 | ('earth', 32, 126), 458 | ('earth', 33, 126), 459 | ('mars', 40, 123), 460 | ('mars', 41, 123), 461 | ('mars', 42, 123), 462 | ('mercury', 10, None), 463 | ('mercury', 11, None), 464 | ('mercury', 12, None), 465 | ('moon', 1, None), 466 | ('moon', 2, None), 467 | ('moon', 3, None), 468 | ('proxima centauri', 2000, 2000), 469 | ('proxima centauri b', 10, None), 470 | ('proxima centauri b', 11, None), 471 | ('proxima centauri b', 12, None), 472 | ('sun', 1000, 1000), 473 | ('venus', 20, None), 474 | ('venus', 21, None), 475 | ('venus', 22, None), 476 | ('venus', 23, None), 477 | ]) 478 | 479 | def test_non_cte_subquery(self): 480 | """ 481 | Verifies that subquery annotations are handled correctly when the 482 | subquery model doesn't use the CTE manager, and the query results 483 | match expected behavior 484 | """ 485 | self.assertNotIsInstance(User.objects, CTEManager) 486 | 487 | sub_totals = With( 488 | Order.objects 489 | .values(region_parent=F("region__parent_id")) 490 | .annotate( 491 | total=Sum("amount"), 492 | # trivial subquery example testing existence of 493 | # a user for the order 494 | non_cte_subquery=Exists( 495 | User.objects.filter(pk=OuterRef("user_id")) 496 | ), 497 | ), 498 | ) 499 | regions = ( 500 | Region.objects.all() 501 | .with_cte(sub_totals) 502 | .annotate( 503 | child_regions_total=Subquery( 504 | sub_totals.queryset() 505 | .filter(region_parent=OuterRef("name")) 506 | .values("total"), 507 | ), 508 | ) 509 | .order_by("name") 510 | ) 511 | print(regions.query) 512 | 513 | data = [(r.name, r.child_regions_total) for r in regions] 514 | self.assertEqual(data, [ 515 | ("bernard's star", None), 516 | ('deimos', None), 517 | ('earth', 6), 518 | ('mars', None), 519 | ('mercury', None), 520 | ('moon', None), 521 | ('phobos', None), 522 | ('proxima centauri', 33), 523 | ('proxima centauri b', None), 524 | ('sun', 368), 525 | ('venus', None) 526 | ]) 527 | 528 | def test_explain(self): 529 | """ 530 | Verifies that using .explain() prepends the EXPLAIN clause in the 531 | correct position 532 | """ 533 | 534 | totals = With( 535 | Order.objects 536 | .filter(region__parent="sun") 537 | .values("region_id") 538 | .annotate(total=Sum("amount")), 539 | name="totals", 540 | ) 541 | region_count = With( 542 | Region.objects 543 | .filter(parent="sun") 544 | .values("parent_id") 545 | .annotate(num=Count("name")), 546 | name="region_count", 547 | ) 548 | orders = ( 549 | region_count.join( 550 | totals.join(Order, region=totals.col.region_id), 551 | region__parent=region_count.col.parent_id 552 | ) 553 | .with_cte(totals) 554 | .with_cte(region_count) 555 | .annotate(region_total=totals.col.total) 556 | .annotate(region_count=region_count.col.num) 557 | .order_by("amount") 558 | ) 559 | print(orders.query) 560 | 561 | self.assertIsInstance(orders.explain(), str) 562 | 563 | def test_empty_result_set_cte(self): 564 | """ 565 | Verifies that the CTEQueryCompiler can handle empty result sets in the 566 | related CTEs 567 | """ 568 | totals = With( 569 | Order.objects 570 | .filter(id__in=[]) 571 | .values("region_id") 572 | .annotate(total=Sum("amount")), 573 | name="totals", 574 | ) 575 | orders = ( 576 | totals.join(Order, region=totals.col.region_id) 577 | .with_cte(totals) 578 | .annotate(region_total=totals.col.total) 579 | .order_by("amount") 580 | ) 581 | 582 | self.assertEqual(len(orders), 0) 583 | 584 | def test_left_outer_join_on_empty_result_set_cte(self): 585 | totals = With( 586 | Order.objects 587 | .filter(id__in=[]) 588 | .values("region_id") 589 | .annotate(total=Sum("amount")), 590 | name="totals", 591 | ) 592 | orders = ( 593 | totals.join(Order, region=totals.col.region_id, _join_type=LOUTER) 594 | .with_cte(totals) 595 | .annotate(region_total=totals.col.total) 596 | .order_by("amount") 597 | ) 598 | 599 | self.assertEqual(len(orders), 22) 600 | 601 | def test_union_query_with_cte(self): 602 | orders = ( 603 | Order.objects 604 | .filter(region__parent="sun") 605 | .only("region", "amount") 606 | ) 607 | orders_cte = With(orders, name="orders_cte") 608 | orders_cte_queryset = orders_cte.queryset() 609 | 610 | earth_orders = orders_cte_queryset.filter(region="earth") 611 | mars_orders = orders_cte_queryset.filter(region="mars") 612 | 613 | earth_mars = earth_orders.union(mars_orders, all=True) 614 | earth_mars_cte = ( 615 | earth_mars 616 | .with_cte(orders_cte) 617 | .order_by("region", "amount") 618 | .values_list("region", "amount") 619 | ) 620 | print(earth_mars_cte.query) 621 | 622 | self.assertEqual(list(earth_mars_cte), [ 623 | ('earth', 30), 624 | ('earth', 31), 625 | ('earth', 32), 626 | ('earth', 33), 627 | ('mars', 40), 628 | ('mars', 41), 629 | ('mars', 42), 630 | ]) 631 | 632 | def test_cte_select_pk(self): 633 | orders = Order.objects.filter(region="earth").values("pk") 634 | cte = With(orders) 635 | queryset = cte.join(orders, pk=cte.col.pk).with_cte(cte).order_by("pk") 636 | print(queryset.query) 637 | self.assertEqual(list(queryset), [ 638 | {'pk': 9}, 639 | {'pk': 10}, 640 | {'pk': 11}, 641 | {'pk': 12}, 642 | ]) 643 | -------------------------------------------------------------------------------- /tests/test_v1/test_django.py: -------------------------------------------------------------------------------- 1 | from unittest import SkipTest 2 | 3 | import django 4 | from django.db import OperationalError, ProgrammingError 5 | from django.db.models import Window 6 | from django.db.models.functions import Rank 7 | from django.test import TestCase, skipUnlessDBFeature 8 | 9 | from .models import Order, Region, User 10 | 11 | 12 | @skipUnlessDBFeature("supports_select_union") 13 | class NonCteQueries(TestCase): 14 | """Test non-CTE queries 15 | 16 | These tests were adapted from the Django test suite. The models used 17 | here use CTEManager and CTEQuerySet to verify feature parity with 18 | their base classes Manager and QuerySet. 19 | """ 20 | 21 | @classmethod 22 | def setUpTestData(cls): 23 | Order.objects.all().delete() 24 | 25 | def test_union_with_select_related_and_order(self): 26 | e1 = User.objects.create(name="e1") 27 | a1 = Order.objects.create(region_id="earth", user=e1) 28 | a2 = Order.objects.create(region_id="moon", user=e1) 29 | Order.objects.create(region_id="sun", user=e1) 30 | base_qs = Order.objects.select_related("user").order_by() 31 | qs1 = base_qs.filter(region_id="earth") 32 | qs2 = base_qs.filter(region_id="moon") 33 | print(qs1.union(qs2).order_by("pk").query) 34 | self.assertSequenceEqual(qs1.union(qs2).order_by("pk"), [a1, a2]) 35 | 36 | @skipUnlessDBFeature("supports_slicing_ordering_in_compound") 37 | def test_union_with_select_related_and_first(self): 38 | e1 = User.objects.create(name="e1") 39 | a1 = Order.objects.create(region_id="earth", user=e1) 40 | Order.objects.create(region_id="moon", user=e1) 41 | base_qs = Order.objects.select_related("user") 42 | qs1 = base_qs.filter(region_id="earth") 43 | qs2 = base_qs.filter(region_id="moon") 44 | self.assertEqual(qs1.union(qs2).first(), a1) 45 | 46 | def test_union_with_first(self): 47 | e1 = User.objects.create(name="e1") 48 | a1 = Order.objects.create(region_id="earth", user=e1) 49 | base_qs = Order.objects.order_by() 50 | qs1 = base_qs.filter(region_id="earth") 51 | qs2 = base_qs.filter(region_id="moon") 52 | self.assertEqual(qs1.union(qs2).first(), a1) 53 | 54 | 55 | class WindowFunctions(TestCase): 56 | 57 | def test_heterogeneous_filter_in_cte(self): 58 | if django.VERSION < (4, 2): 59 | raise SkipTest("feature added in Django 4.2") 60 | from django_cte import With 61 | cte = With( 62 | Order.objects.annotate( 63 | region_amount_rank=Window( 64 | Rank(), partition_by="region_id", order_by="-amount" 65 | ), 66 | ) 67 | .order_by("region_id") 68 | .values("region_id", "region_amount_rank") 69 | .filter(region_amount_rank=1, region_id__in=["sun", "moon"]) 70 | ) 71 | qs = cte.join(Region, name=cte.col.region_id).with_cte(cte) 72 | print(qs.query) 73 | # ProgrammingError: column cte.region_id does not exist 74 | # WITH RECURSIVE "cte" AS (SELECT * FROM ( 75 | # SELECT "orders"."region_id" AS "col1", ... 76 | # "region" INNER JOIN "cte" ON "region"."name" = ("cte"."region_id") 77 | try: 78 | self.assertEqual({r.name for r in qs}, {"moon", "sun"}) 79 | except (OperationalError, ProgrammingError) as err: 80 | if "cte.region_id" in str(err): 81 | raise SkipTest( 82 | "window function auto-aliasing breaks CTE " 83 | "column references" 84 | ) 85 | raise 86 | if django.VERSION < (5, 2): 87 | assert 0, "unexpected pass" 88 | -------------------------------------------------------------------------------- /tests/test_v1/test_manager.py: -------------------------------------------------------------------------------- 1 | from django.db.models.expressions import F 2 | from django.db.models.query import QuerySet 3 | from django.test import TestCase 4 | 5 | from django_cte import With, CTEQuerySet, CTEManager 6 | 7 | from .models import ( 8 | Order, 9 | OrderFromLT40, 10 | OrderLT40AsManager, 11 | OrderCustomManagerNQuery, 12 | OrderCustomManager, 13 | LT40QuerySet, 14 | LTManager, 15 | LT25QuerySet, 16 | ) 17 | 18 | 19 | class TestCTE(TestCase): 20 | def test_cte_queryset_correct_defaultmanager(self): 21 | self.assertEqual(type(Order._default_manager), CTEManager) 22 | self.assertEqual(type(Order.objects.all()), CTEQuerySet) 23 | 24 | def test_cte_queryset_correct_from_queryset(self): 25 | self.assertEqual(type(OrderFromLT40.objects.all()), LT40QuerySet) 26 | 27 | def test_cte_queryset_correct_queryset_as_manager(self): 28 | self.assertEqual(type(OrderLT40AsManager.objects.all()), LT40QuerySet) 29 | 30 | def test_cte_queryset_correct_manager_n_from_queryset(self): 31 | self.assertIsInstance( 32 | OrderCustomManagerNQuery._default_manager, LTManager) 33 | self.assertEqual(type( 34 | OrderCustomManagerNQuery.objects.all()), LT25QuerySet) 35 | 36 | def test_cte_create_manager_from_non_cteQuery(self): 37 | class BrokenQuerySet(QuerySet): 38 | "This should be a CTEQuerySet if we want this to work" 39 | 40 | with self.assertRaises(TypeError): 41 | CTEManager.from_queryset(BrokenQuerySet)() 42 | 43 | def test_cte_queryset_correct_limitedmanager(self): 44 | self.assertEqual(type(OrderCustomManager._default_manager), LTManager) 45 | # Check the expected even if not ideal behavior occurs 46 | self.assertIsInstance(OrderCustomManager.objects.all(), CTEQuerySet) 47 | 48 | def test_cte_queryset_with_from_queryset(self): 49 | self.assertEqual(type(OrderFromLT40.objects.all()), LT40QuerySet) 50 | 51 | cte = With( 52 | OrderFromLT40.objects 53 | .annotate(region_parent=F("region__parent_id")) 54 | .filter(region__parent_id="sun") 55 | ) 56 | orders = ( 57 | cte.queryset() 58 | .with_cte(cte) 59 | .lt40() # custom queryset method 60 | .order_by("region_id", "amount") 61 | ) 62 | print(orders.query) 63 | 64 | data = [(x.region_id, x.amount, x.region_parent) for x in orders] 65 | self.assertEqual(data, [ 66 | ("earth", 30, "sun"), 67 | ("earth", 31, "sun"), 68 | ("earth", 32, "sun"), 69 | ("earth", 33, "sun"), 70 | ('mercury', 10, 'sun'), 71 | ('mercury', 11, 'sun'), 72 | ('mercury', 12, 'sun'), 73 | ('venus', 20, 'sun'), 74 | ('venus', 21, 'sun'), 75 | ('venus', 22, 'sun'), 76 | ('venus', 23, 'sun'), 77 | ]) 78 | 79 | def test_cte_queryset_with_custom_queryset(self): 80 | cte = With( 81 | OrderCustomManagerNQuery.objects 82 | .annotate(region_parent=F("region__parent_id")) 83 | .filter(region__parent_id="sun") 84 | ) 85 | orders = ( 86 | cte.queryset() 87 | .with_cte(cte) 88 | .lt25() # custom queryset method 89 | .order_by("region_id", "amount") 90 | ) 91 | print(orders.query) 92 | 93 | data = [(x.region_id, x.amount, x.region_parent) for x in orders] 94 | self.assertEqual(data, [ 95 | ('mercury', 10, 'sun'), 96 | ('mercury', 11, 'sun'), 97 | ('mercury', 12, 'sun'), 98 | ('venus', 20, 'sun'), 99 | ('venus', 21, 'sun'), 100 | ('venus', 22, 'sun'), 101 | ('venus', 23, 'sun'), 102 | ]) 103 | 104 | def test_cte_queryset_with_deferred_loading(self): 105 | cte = With( 106 | OrderCustomManagerNQuery.objects.order_by("id").only("id")[:1] 107 | ) 108 | orders = cte.queryset().with_cte(cte) 109 | print(orders.query) 110 | 111 | self.assertEqual([x.id for x in orders], [1]) 112 | -------------------------------------------------------------------------------- /tests/test_v1/test_raw.py: -------------------------------------------------------------------------------- 1 | from django.db.models import IntegerField, TextField 2 | from django.test import TestCase 3 | 4 | from django_cte import With 5 | from django_cte.raw import raw_cte_sql 6 | 7 | from .models import Region 8 | 9 | int_field = IntegerField() 10 | text_field = TextField() 11 | 12 | 13 | class TestRawCTE(TestCase): 14 | 15 | def test_raw_cte_sql(self): 16 | cte = With(raw_cte_sql( 17 | """ 18 | SELECT region_id, AVG(amount) AS avg_order 19 | FROM orders 20 | WHERE region_id = %s 21 | GROUP BY region_id 22 | """, 23 | ["moon"], 24 | {"region_id": text_field, "avg_order": int_field}, 25 | )) 26 | moon_avg = ( 27 | cte 28 | .join(Region, name=cte.col.region_id) 29 | .annotate(avg_order=cte.col.avg_order) 30 | .with_cte(cte) 31 | ) 32 | print(moon_avg.query) 33 | 34 | data = [(r.name, r.parent.name, r.avg_order) for r in moon_avg] 35 | self.assertEqual(data, [('moon', 'earth', 2)]) 36 | 37 | def test_raw_cte_sql_name_escape(self): 38 | cte = With( 39 | raw_cte_sql( 40 | """ 41 | SELECT region_id, AVG(amount) AS avg_order 42 | FROM orders 43 | WHERE region_id = %s 44 | GROUP BY region_id 45 | """, 46 | ["moon"], 47 | {"region_id": text_field, "avg_order": int_field}, 48 | ), 49 | name="mixedCaseCTEName" 50 | ) 51 | moon_avg = ( 52 | cte 53 | .join(Region, name=cte.col.region_id) 54 | .annotate(avg_order=cte.col.avg_order) 55 | .with_cte(cte) 56 | ) 57 | self.assertTrue( 58 | str(moon_avg.query).startswith( 59 | 'WITH RECURSIVE "mixedCaseCTEName"') 60 | ) 61 | -------------------------------------------------------------------------------- /tests/test_v1/test_recursive.py: -------------------------------------------------------------------------------- 1 | import pickle 2 | from unittest import SkipTest 3 | 4 | from django.db.models import IntegerField, TextField 5 | from django.db.models.expressions import ( 6 | Case, 7 | Exists, 8 | ExpressionWrapper, 9 | F, 10 | OuterRef, 11 | Q, 12 | Value, 13 | When, 14 | ) 15 | from django.db.models.functions import Concat 16 | from django.db.utils import DatabaseError 17 | from django.test import TestCase 18 | 19 | from django_cte import With 20 | 21 | from .models import KeyPair, Region 22 | 23 | int_field = IntegerField() 24 | text_field = TextField() 25 | 26 | 27 | class TestRecursiveCTE(TestCase): 28 | 29 | def test_recursive_cte_query(self): 30 | def make_regions_cte(cte): 31 | return Region.objects.filter( 32 | # non-recursive: get root nodes 33 | parent__isnull=True 34 | ).values( 35 | "name", 36 | path=F("name"), 37 | depth=Value(0, output_field=int_field), 38 | ).union( 39 | # recursive union: get descendants 40 | cte.join(Region, parent=cte.col.name).values( 41 | "name", 42 | path=Concat( 43 | cte.col.path, Value(" / "), F("name"), 44 | output_field=text_field, 45 | ), 46 | depth=cte.col.depth + Value(1, output_field=int_field), 47 | ), 48 | all=True, 49 | ) 50 | 51 | cte = With.recursive(make_regions_cte) 52 | 53 | regions = ( 54 | cte.join(Region, name=cte.col.name) 55 | .with_cte(cte) 56 | .annotate( 57 | path=cte.col.path, 58 | depth=cte.col.depth, 59 | ) 60 | .filter(depth=2) 61 | .order_by("path") 62 | ) 63 | print(regions.query) 64 | 65 | data = [(r.name, r.path, r.depth) for r in regions] 66 | self.assertEqual(data, [ 67 | ('moon', 'sun / earth / moon', 2), 68 | ('deimos', 'sun / mars / deimos', 2), 69 | ('phobos', 'sun / mars / phobos', 2), 70 | ]) 71 | 72 | def test_recursive_cte_reference_in_condition(self): 73 | def make_regions_cte(cte): 74 | return Region.objects.filter( 75 | parent__isnull=True 76 | ).values( 77 | "name", 78 | path=F("name"), 79 | depth=Value(0, output_field=int_field), 80 | is_planet=Value(0, output_field=int_field), 81 | ).union( 82 | cte.join( 83 | Region, parent=cte.col.name 84 | ).annotate( 85 | # annotations for filter and CASE/WHEN conditions 86 | parent_name=ExpressionWrapper( 87 | cte.col.name, 88 | output_field=text_field, 89 | ), 90 | parent_depth=ExpressionWrapper( 91 | cte.col.depth, 92 | output_field=int_field, 93 | ), 94 | ).filter( 95 | ~Q(parent_name="mars"), 96 | ).values( 97 | "name", 98 | path=Concat( 99 | cte.col.path, Value("\x01"), F("name"), 100 | output_field=text_field, 101 | ), 102 | depth=cte.col.depth + Value(1, output_field=int_field), 103 | is_planet=Case( 104 | When(parent_depth=0, then=Value(1)), 105 | default=Value(0), 106 | output_field=int_field, 107 | ), 108 | ), 109 | all=True, 110 | ) 111 | cte = With.recursive(make_regions_cte) 112 | regions = cte.join(Region, name=cte.col.name).with_cte(cte).annotate( 113 | path=cte.col.path, 114 | depth=cte.col.depth, 115 | is_planet=cte.col.is_planet, 116 | ).order_by("path") 117 | 118 | data = [(r.path.split("\x01"), r.is_planet) for r in regions] 119 | print(data) 120 | self.assertEqual(data, [ 121 | (["bernard's star"], 0), 122 | (['proxima centauri'], 0), 123 | (['proxima centauri', 'proxima centauri b'], 1), 124 | (['sun'], 0), 125 | (['sun', 'earth'], 1), 126 | (['sun', 'earth', 'moon'], 0), 127 | (['sun', 'mars'], 1), # mars moons excluded: parent_name != 'mars' 128 | (['sun', 'mercury'], 1), 129 | (['sun', 'venus'], 1), 130 | ]) 131 | 132 | def test_recursive_cte_with_empty_union_part(self): 133 | def make_regions_cte(cte): 134 | return Region.objects.none().union( 135 | cte.join(Region, parent=cte.col.name), 136 | all=True, 137 | ) 138 | cte = With.recursive(make_regions_cte) 139 | regions = cte.join(Region, name=cte.col.name).with_cte(cte) 140 | 141 | print(regions.query) 142 | try: 143 | self.assertEqual(regions.count(), 0) 144 | except DatabaseError: 145 | raise SkipTest( 146 | "Expected failure: QuerySet omits `EmptyQuerySet` from " 147 | "UNION queries resulting in invalid CTE SQL" 148 | ) 149 | 150 | # -- recursive query "cte" does not have the form 151 | # -- non-recursive-term UNION [ALL] recursive-term 152 | # WITH RECURSIVE cte AS ( 153 | # SELECT "tests_region"."name", "tests_region"."parent_id" 154 | # FROM "tests_region", "cte" 155 | # WHERE "tests_region"."parent_id" = ("cte"."name") 156 | # ) 157 | # SELECT COUNT(*) 158 | # FROM "tests_region", "cte" 159 | # WHERE "tests_region"."name" = ("cte"."name") 160 | 161 | def test_circular_ref_error(self): 162 | def make_bad_cte(cte): 163 | # NOTE: not a valid recursive CTE query 164 | return cte.join(Region, parent=cte.col.name).values( 165 | depth=cte.col.depth + 1, 166 | ) 167 | cte = With.recursive(make_bad_cte) 168 | regions = cte.join(Region, name=cte.col.name).with_cte(cte) 169 | with self.assertRaises(ValueError) as context: 170 | print(regions.query) 171 | self.assertIn("Circular reference:", str(context.exception)) 172 | 173 | def test_attname_should_not_mask_col_name(self): 174 | def make_regions_cte(cte): 175 | return Region.objects.filter( 176 | name="moon" 177 | ).values( 178 | "name", 179 | "parent_id", 180 | ).union( 181 | cte.join(Region, name=cte.col.parent_id).values( 182 | "name", 183 | "parent_id", 184 | ), 185 | all=True, 186 | ) 187 | cte = With.recursive(make_regions_cte) 188 | regions = ( 189 | Region.objects.all() 190 | .with_cte(cte) 191 | .annotate(_ex=Exists( 192 | cte.queryset() 193 | .values(value=Value("1", output_field=int_field)) 194 | .filter(name=OuterRef("name")) 195 | )) 196 | .filter(_ex=True) 197 | .order_by("name") 198 | ) 199 | print(regions.query) 200 | 201 | data = [r.name for r in regions] 202 | self.assertEqual(data, ['earth', 'moon', 'sun']) 203 | 204 | def test_pickle_recursive_cte_queryset(self): 205 | def make_regions_cte(cte): 206 | return Region.objects.filter( 207 | parent__isnull=True 208 | ).annotate( 209 | depth=Value(0, output_field=int_field), 210 | ).union( 211 | cte.join(Region, parent=cte.col.name).annotate( 212 | depth=cte.col.depth + Value(1, output_field=int_field), 213 | ), 214 | all=True, 215 | ) 216 | cte = With.recursive(make_regions_cte) 217 | regions = cte.queryset().with_cte(cte).filter(depth=2).order_by("name") 218 | 219 | pickled_qs = pickle.loads(pickle.dumps(regions)) 220 | 221 | data = [(r.name, r.depth) for r in pickled_qs] 222 | self.assertEqual(data, [(r.name, r.depth) for r in regions]) 223 | self.assertEqual(data, [('deimos', 2), ('moon', 2), ('phobos', 2)]) 224 | 225 | def test_alias_change_in_annotation(self): 226 | def make_regions_cte(cte): 227 | return Region.objects.filter( 228 | parent__name="sun", 229 | ).annotate( 230 | value=F('name'), 231 | ).union( 232 | cte.join( 233 | Region.objects.all().annotate( 234 | value=F('name'), 235 | ), 236 | parent_id=cte.col.name, 237 | ), 238 | all=True, 239 | ) 240 | cte = With.recursive(make_regions_cte) 241 | query = cte.queryset().with_cte(cte) 242 | 243 | exclude_leaves = With(cte.queryset().filter( 244 | parent__name='sun', 245 | ).annotate( 246 | value=Concat(F('name'), F('name')) 247 | ), name='value_cte') 248 | 249 | query = query.annotate( 250 | _exclude_leaves=Exists( 251 | exclude_leaves.queryset().filter( 252 | name=OuterRef("name"), 253 | value=OuterRef("value"), 254 | ) 255 | ) 256 | ).filter(_exclude_leaves=True).with_cte(exclude_leaves) 257 | print(query.query) 258 | 259 | # Nothing should be returned. 260 | self.assertFalse(query) 261 | 262 | def test_alias_as_subquery(self): 263 | # This test covers CTEColumnRef.relabeled_clone 264 | def make_regions_cte(cte): 265 | return KeyPair.objects.filter( 266 | parent__key="level 1", 267 | ).annotate( 268 | rank=F('value'), 269 | ).union( 270 | cte.join( 271 | KeyPair.objects.all().order_by(), 272 | parent_id=cte.col.id, 273 | ).annotate( 274 | rank=F('value'), 275 | ), 276 | all=True, 277 | ) 278 | cte = With.recursive(make_regions_cte) 279 | children = cte.queryset().with_cte(cte) 280 | 281 | xdups = With(cte.queryset().filter( 282 | parent__key="level 1", 283 | ).annotate( 284 | rank=F('value') 285 | ).values('id', 'rank'), name='xdups') 286 | 287 | children = children.annotate( 288 | _exclude=Exists( 289 | ( 290 | xdups.queryset().filter( 291 | id=OuterRef("id"), 292 | rank=OuterRef("rank"), 293 | ) 294 | ) 295 | ) 296 | ).filter(_exclude=True).with_cte(xdups) 297 | 298 | print(children.query) 299 | query = KeyPair.objects.filter(parent__in=children) 300 | print(query.query) 301 | print(children.query) 302 | self.assertEqual(query.get().key, 'level 3') 303 | # Tests the case in which children's query was modified since it was 304 | # used in a subquery to define `query` above. 305 | self.assertEqual( 306 | list(c.key for c in children), 307 | ['level 2', 'level 2'] 308 | ) 309 | 310 | def test_materialized(self): 311 | # This test covers MATERIALIZED option in SQL query 312 | def make_regions_cte(cte): 313 | return KeyPair.objects.all() 314 | cte = With.recursive(make_regions_cte, materialized=True) 315 | 316 | query = KeyPair.objects.with_cte(cte) 317 | print(query.query) 318 | self.assertTrue( 319 | str(query.query).startswith('WITH RECURSIVE "cte" AS MATERIALIZED') 320 | ) 321 | 322 | def test_recursive_self_queryset(self): 323 | def make_regions_cte(cte): 324 | return Region.objects.filter( 325 | pk="earth" 326 | ).values("pk").union( 327 | cte.join(Region, parent=cte.col.pk).values("pk") 328 | ) 329 | cte = With.recursive(make_regions_cte) 330 | queryset = cte.queryset().with_cte(cte).order_by("pk") 331 | print(queryset.query) 332 | self.assertEqual(list(queryset), [ 333 | {'pk': 'earth'}, 334 | {'pk': 'moon'}, 335 | ]) 336 | -------------------------------------------------------------------------------- /uv.lock: -------------------------------------------------------------------------------- 1 | version = 1 2 | revision = 2 3 | requires-python = ">=3.9" 4 | resolution-markers = [ 5 | "python_full_version >= '3.10'", 6 | "python_full_version < '3.10'", 7 | ] 8 | 9 | [[package]] 10 | name = "asgiref" 11 | version = "3.8.1" 12 | source = { registry = "https://pypi.org/simple" } 13 | dependencies = [ 14 | { name = "typing-extensions", marker = "python_full_version < '3.11'" }, 15 | ] 16 | sdist = { url = "https://files.pythonhosted.org/packages/29/38/b3395cc9ad1b56d2ddac9970bc8f4141312dbaec28bc7c218b0dfafd0f42/asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590", size = 35186, upload-time = "2024-03-22T14:39:36.863Z" } 17 | wheels = [ 18 | { url = "https://files.pythonhosted.org/packages/39/e3/893e8757be2612e6c266d9bb58ad2e3651524b5b40cf56761e985a28b13e/asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47", size = 23828, upload-time = "2024-03-22T14:39:34.521Z" }, 19 | ] 20 | 21 | [[package]] 22 | name = "colorama" 23 | version = "0.4.6" 24 | source = { registry = "https://pypi.org/simple" } 25 | sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } 26 | wheels = [ 27 | { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, 28 | ] 29 | 30 | [[package]] 31 | name = "django" 32 | version = "4.2.21" 33 | source = { registry = "https://pypi.org/simple" } 34 | dependencies = [ 35 | { name = "asgiref" }, 36 | { name = "sqlparse" }, 37 | { name = "tzdata", marker = "sys_platform == 'win32'" }, 38 | ] 39 | sdist = { url = "https://files.pythonhosted.org/packages/c1/bb/2fad5edc1af2945cb499a2e322ac28e4714fc310bd5201ed1f5a9f73a342/django-4.2.21.tar.gz", hash = "sha256:b54ac28d6aa964fc7c2f7335138a54d78980232011e0cd2231d04eed393dcb0d", size = 10424638, upload-time = "2025-05-07T14:07:07.992Z" } 40 | wheels = [ 41 | { url = "https://files.pythonhosted.org/packages/2c/4f/aeaa3098da18b625ed672f3da6d1cd94e188d1b2cc27c2c841b2f9666282/django-4.2.21-py3-none-any.whl", hash = "sha256:1d658c7bf5d31c7d0cac1cab58bc1f822df89255080fec81909256c30e6180b3", size = 7993839, upload-time = "2025-05-07T14:07:01.318Z" }, 42 | ] 43 | 44 | [[package]] 45 | name = "django-cte" 46 | source = { editable = "." } 47 | dependencies = [ 48 | { name = "django" }, 49 | ] 50 | 51 | [package.dev-dependencies] 52 | dev = [ 53 | { name = "psycopg2-binary" }, 54 | { name = "pytest-unmagic" }, 55 | { name = "ruff" }, 56 | ] 57 | 58 | [package.metadata] 59 | requires-dist = [{ name = "django" }] 60 | 61 | [package.metadata.requires-dev] 62 | dev = [ 63 | { name = "psycopg2-binary" }, 64 | { name = "pytest-unmagic" }, 65 | { name = "ruff" }, 66 | ] 67 | 68 | [[package]] 69 | name = "exceptiongroup" 70 | version = "1.3.0" 71 | source = { registry = "https://pypi.org/simple" } 72 | dependencies = [ 73 | { name = "typing-extensions", marker = "python_full_version < '3.13'" }, 74 | ] 75 | sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } 76 | wheels = [ 77 | { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, 78 | ] 79 | 80 | [[package]] 81 | name = "iniconfig" 82 | version = "2.1.0" 83 | source = { registry = "https://pypi.org/simple" } 84 | sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } 85 | wheels = [ 86 | { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, 87 | ] 88 | 89 | [[package]] 90 | name = "packaging" 91 | version = "25.0" 92 | source = { registry = "https://pypi.org/simple" } 93 | sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } 94 | wheels = [ 95 | { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, 96 | ] 97 | 98 | [[package]] 99 | name = "pluggy" 100 | version = "1.6.0" 101 | source = { registry = "https://pypi.org/simple" } 102 | sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } 103 | wheels = [ 104 | { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, 105 | ] 106 | 107 | [[package]] 108 | name = "psycopg2-binary" 109 | version = "2.9.10" 110 | source = { registry = "https://pypi.org/simple" } 111 | sdist = { url = "https://files.pythonhosted.org/packages/cb/0e/bdc8274dc0585090b4e3432267d7be4dfbfd8971c0fa59167c711105a6bf/psycopg2-binary-2.9.10.tar.gz", hash = "sha256:4b3df0e6990aa98acda57d983942eff13d824135fe2250e6522edaa782a06de2", size = 385764, upload-time = "2024-10-16T11:24:58.126Z" } 112 | wheels = [ 113 | { url = "https://files.pythonhosted.org/packages/7a/81/331257dbf2801cdb82105306042f7a1637cc752f65f2bb688188e0de5f0b/psycopg2_binary-2.9.10-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:0ea8e3d0ae83564f2fc554955d327fa081d065c8ca5cc6d2abb643e2c9c1200f", size = 3043397, upload-time = "2024-10-16T11:18:58.647Z" }, 114 | { url = "https://files.pythonhosted.org/packages/e7/9a/7f4f2f031010bbfe6a02b4a15c01e12eb6b9b7b358ab33229f28baadbfc1/psycopg2_binary-2.9.10-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:3e9c76f0ac6f92ecfc79516a8034a544926430f7b080ec5a0537bca389ee0906", size = 3274806, upload-time = "2024-10-16T11:19:03.935Z" }, 115 | { url = "https://files.pythonhosted.org/packages/e5/57/8ddd4b374fa811a0b0a0f49b6abad1cde9cb34df73ea3348cc283fcd70b4/psycopg2_binary-2.9.10-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ad26b467a405c798aaa1458ba09d7e2b6e5f96b1ce0ac15d82fd9f95dc38a92", size = 2851361, upload-time = "2024-10-16T11:19:07.277Z" }, 116 | { url = "https://files.pythonhosted.org/packages/f9/66/d1e52c20d283f1f3a8e7e5c1e06851d432f123ef57b13043b4f9b21ffa1f/psycopg2_binary-2.9.10-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:270934a475a0e4b6925b5f804e3809dd5f90f8613621d062848dd82f9cd62007", size = 3080836, upload-time = "2024-10-16T11:19:11.033Z" }, 117 | { url = "https://files.pythonhosted.org/packages/a0/cb/592d44a9546aba78f8a1249021fe7c59d3afb8a0ba51434d6610cc3462b6/psycopg2_binary-2.9.10-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:48b338f08d93e7be4ab2b5f1dbe69dc5e9ef07170fe1f86514422076d9c010d0", size = 3264552, upload-time = "2024-10-16T11:19:14.606Z" }, 118 | { url = "https://files.pythonhosted.org/packages/64/33/c8548560b94b7617f203d7236d6cdf36fe1a5a3645600ada6efd79da946f/psycopg2_binary-2.9.10-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f4152f8f76d2023aac16285576a9ecd2b11a9895373a1f10fd9db54b3ff06b4", size = 3019789, upload-time = "2024-10-16T11:19:18.889Z" }, 119 | { url = "https://files.pythonhosted.org/packages/b0/0e/c2da0db5bea88a3be52307f88b75eec72c4de62814cbe9ee600c29c06334/psycopg2_binary-2.9.10-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:32581b3020c72d7a421009ee1c6bf4a131ef5f0a968fab2e2de0c9d2bb4577f1", size = 2871776, upload-time = "2024-10-16T11:19:23.023Z" }, 120 | { url = "https://files.pythonhosted.org/packages/15/d7/774afa1eadb787ddf41aab52d4c62785563e29949613c958955031408ae6/psycopg2_binary-2.9.10-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:2ce3e21dc3437b1d960521eca599d57408a695a0d3c26797ea0f72e834c7ffe5", size = 2820959, upload-time = "2024-10-16T11:19:26.906Z" }, 121 | { url = "https://files.pythonhosted.org/packages/5e/ed/440dc3f5991a8c6172a1cde44850ead0e483a375277a1aef7cfcec00af07/psycopg2_binary-2.9.10-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e984839e75e0b60cfe75e351db53d6db750b00de45644c5d1f7ee5d1f34a1ce5", size = 2919329, upload-time = "2024-10-16T11:19:30.027Z" }, 122 | { url = "https://files.pythonhosted.org/packages/03/be/2cc8f4282898306732d2ae7b7378ae14e8df3c1231b53579efa056aae887/psycopg2_binary-2.9.10-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3c4745a90b78e51d9ba06e2088a2fe0c693ae19cc8cb051ccda44e8df8a6eb53", size = 2957659, upload-time = "2024-10-16T11:19:32.864Z" }, 123 | { url = "https://files.pythonhosted.org/packages/d0/12/fb8e4f485d98c570e00dad5800e9a2349cfe0f71a767c856857160d343a5/psycopg2_binary-2.9.10-cp310-cp310-win32.whl", hash = "sha256:e5720a5d25e3b99cd0dc5c8a440570469ff82659bb09431c1439b92caf184d3b", size = 1024605, upload-time = "2024-10-16T11:19:35.462Z" }, 124 | { url = "https://files.pythonhosted.org/packages/22/4f/217cd2471ecf45d82905dd09085e049af8de6cfdc008b6663c3226dc1c98/psycopg2_binary-2.9.10-cp310-cp310-win_amd64.whl", hash = "sha256:3c18f74eb4386bf35e92ab2354a12c17e5eb4d9798e4c0ad3a00783eae7cd9f1", size = 1163817, upload-time = "2024-10-16T11:19:37.384Z" }, 125 | { url = "https://files.pythonhosted.org/packages/9c/8f/9feb01291d0d7a0a4c6a6bab24094135c2b59c6a81943752f632c75896d6/psycopg2_binary-2.9.10-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:04392983d0bb89a8717772a193cfaac58871321e3ec69514e1c4e0d4957b5aff", size = 3043397, upload-time = "2024-10-16T11:19:40.033Z" }, 126 | { url = "https://files.pythonhosted.org/packages/15/30/346e4683532011561cd9c8dfeac6a8153dd96452fee0b12666058ab7893c/psycopg2_binary-2.9.10-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:1a6784f0ce3fec4edc64e985865c17778514325074adf5ad8f80636cd029ef7c", size = 3274806, upload-time = "2024-10-16T11:19:43.5Z" }, 127 | { url = "https://files.pythonhosted.org/packages/66/6e/4efebe76f76aee7ec99166b6c023ff8abdc4e183f7b70913d7c047701b79/psycopg2_binary-2.9.10-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5f86c56eeb91dc3135b3fd8a95dc7ae14c538a2f3ad77a19645cf55bab1799c", size = 2851370, upload-time = "2024-10-16T11:19:46.986Z" }, 128 | { url = "https://files.pythonhosted.org/packages/7f/fd/ff83313f86b50f7ca089b161b8e0a22bb3c319974096093cd50680433fdb/psycopg2_binary-2.9.10-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2b3d2491d4d78b6b14f76881905c7a8a8abcf974aad4a8a0b065273a0ed7a2cb", size = 3080780, upload-time = "2024-10-16T11:19:50.242Z" }, 129 | { url = "https://files.pythonhosted.org/packages/e6/c4/bfadd202dcda8333a7ccafdc51c541dbdfce7c2c7cda89fa2374455d795f/psycopg2_binary-2.9.10-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2286791ececda3a723d1910441c793be44625d86d1a4e79942751197f4d30341", size = 3264583, upload-time = "2024-10-16T11:19:54.424Z" }, 130 | { url = "https://files.pythonhosted.org/packages/5d/f1/09f45ac25e704ac954862581f9f9ae21303cc5ded3d0b775532b407f0e90/psycopg2_binary-2.9.10-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:512d29bb12608891e349af6a0cccedce51677725a921c07dba6342beaf576f9a", size = 3019831, upload-time = "2024-10-16T11:19:57.762Z" }, 131 | { url = "https://files.pythonhosted.org/packages/9e/2e/9beaea078095cc558f215e38f647c7114987d9febfc25cb2beed7c3582a5/psycopg2_binary-2.9.10-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5a507320c58903967ef7384355a4da7ff3f28132d679aeb23572753cbf2ec10b", size = 2871822, upload-time = "2024-10-16T11:20:04.693Z" }, 132 | { url = "https://files.pythonhosted.org/packages/01/9e/ef93c5d93f3dc9fc92786ffab39e323b9aed066ba59fdc34cf85e2722271/psycopg2_binary-2.9.10-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6d4fa1079cab9018f4d0bd2db307beaa612b0d13ba73b5c6304b9fe2fb441ff7", size = 2820975, upload-time = "2024-10-16T11:20:11.401Z" }, 133 | { url = "https://files.pythonhosted.org/packages/a5/f0/049e9631e3268fe4c5a387f6fc27e267ebe199acf1bc1bc9cbde4bd6916c/psycopg2_binary-2.9.10-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:851485a42dbb0bdc1edcdabdb8557c09c9655dfa2ca0460ff210522e073e319e", size = 2919320, upload-time = "2024-10-16T11:20:17.959Z" }, 134 | { url = "https://files.pythonhosted.org/packages/dc/9a/bcb8773b88e45fb5a5ea8339e2104d82c863a3b8558fbb2aadfe66df86b3/psycopg2_binary-2.9.10-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:35958ec9e46432d9076286dda67942ed6d968b9c3a6a2fd62b48939d1d78bf68", size = 2957617, upload-time = "2024-10-16T11:20:24.711Z" }, 135 | { url = "https://files.pythonhosted.org/packages/e2/6b/144336a9bf08a67d217b3af3246abb1d027095dab726f0687f01f43e8c03/psycopg2_binary-2.9.10-cp311-cp311-win32.whl", hash = "sha256:ecced182e935529727401b24d76634a357c71c9275b356efafd8a2a91ec07392", size = 1024618, upload-time = "2024-10-16T11:20:27.718Z" }, 136 | { url = "https://files.pythonhosted.org/packages/61/69/3b3d7bd583c6d3cbe5100802efa5beacaacc86e37b653fc708bf3d6853b8/psycopg2_binary-2.9.10-cp311-cp311-win_amd64.whl", hash = "sha256:ee0e8c683a7ff25d23b55b11161c2663d4b099770f6085ff0a20d4505778d6b4", size = 1163816, upload-time = "2024-10-16T11:20:30.777Z" }, 137 | { url = "https://files.pythonhosted.org/packages/49/7d/465cc9795cf76f6d329efdafca74693714556ea3891813701ac1fee87545/psycopg2_binary-2.9.10-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:880845dfe1f85d9d5f7c412efea7a08946a46894537e4e5d091732eb1d34d9a0", size = 3044771, upload-time = "2024-10-16T11:20:35.234Z" }, 138 | { url = "https://files.pythonhosted.org/packages/8b/31/6d225b7b641a1a2148e3ed65e1aa74fc86ba3fee850545e27be9e1de893d/psycopg2_binary-2.9.10-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9440fa522a79356aaa482aa4ba500b65f28e5d0e63b801abf6aa152a29bd842a", size = 3275336, upload-time = "2024-10-16T11:20:38.742Z" }, 139 | { url = "https://files.pythonhosted.org/packages/30/b7/a68c2b4bff1cbb1728e3ec864b2d92327c77ad52edcd27922535a8366f68/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3923c1d9870c49a2d44f795df0c889a22380d36ef92440ff618ec315757e539", size = 2851637, upload-time = "2024-10-16T11:20:42.145Z" }, 140 | { url = "https://files.pythonhosted.org/packages/0b/b1/cfedc0e0e6f9ad61f8657fd173b2f831ce261c02a08c0b09c652b127d813/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b2c956c028ea5de47ff3a8d6b3cc3330ab45cf0b7c3da35a2d6ff8420896526", size = 3082097, upload-time = "2024-10-16T11:20:46.185Z" }, 141 | { url = "https://files.pythonhosted.org/packages/18/ed/0a8e4153c9b769f59c02fb5e7914f20f0b2483a19dae7bf2db54b743d0d0/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f758ed67cab30b9a8d2833609513ce4d3bd027641673d4ebc9c067e4d208eec1", size = 3264776, upload-time = "2024-10-16T11:20:50.879Z" }, 142 | { url = "https://files.pythonhosted.org/packages/10/db/d09da68c6a0cdab41566b74e0a6068a425f077169bed0946559b7348ebe9/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cd9b4f2cfab88ed4a9106192de509464b75a906462fb846b936eabe45c2063e", size = 3020968, upload-time = "2024-10-16T11:20:56.819Z" }, 143 | { url = "https://files.pythonhosted.org/packages/94/28/4d6f8c255f0dfffb410db2b3f9ac5218d959a66c715c34cac31081e19b95/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dc08420625b5a20b53551c50deae6e231e6371194fa0651dbe0fb206452ae1f", size = 2872334, upload-time = "2024-10-16T11:21:02.411Z" }, 144 | { url = "https://files.pythonhosted.org/packages/05/f7/20d7bf796593c4fea95e12119d6cc384ff1f6141a24fbb7df5a668d29d29/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d7cd730dfa7c36dbe8724426bf5612798734bff2d3c3857f36f2733f5bfc7c00", size = 2822722, upload-time = "2024-10-16T11:21:09.01Z" }, 145 | { url = "https://files.pythonhosted.org/packages/4d/e4/0c407ae919ef626dbdb32835a03b6737013c3cc7240169843965cada2bdf/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:155e69561d54d02b3c3209545fb08938e27889ff5a10c19de8d23eb5a41be8a5", size = 2920132, upload-time = "2024-10-16T11:21:16.339Z" }, 146 | { url = "https://files.pythonhosted.org/packages/2d/70/aa69c9f69cf09a01da224909ff6ce8b68faeef476f00f7ec377e8f03be70/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3cc28a6fd5a4a26224007712e79b81dbaee2ffb90ff406256158ec4d7b52b47", size = 2959312, upload-time = "2024-10-16T11:21:25.584Z" }, 147 | { url = "https://files.pythonhosted.org/packages/d3/bd/213e59854fafe87ba47814bf413ace0dcee33a89c8c8c814faca6bc7cf3c/psycopg2_binary-2.9.10-cp312-cp312-win32.whl", hash = "sha256:ec8a77f521a17506a24a5f626cb2aee7850f9b69a0afe704586f63a464f3cd64", size = 1025191, upload-time = "2024-10-16T11:21:29.912Z" }, 148 | { url = "https://files.pythonhosted.org/packages/92/29/06261ea000e2dc1e22907dbbc483a1093665509ea586b29b8986a0e56733/psycopg2_binary-2.9.10-cp312-cp312-win_amd64.whl", hash = "sha256:18c5ee682b9c6dd3696dad6e54cc7ff3a1a9020df6a5c0f861ef8bfd338c3ca0", size = 1164031, upload-time = "2024-10-16T11:21:34.211Z" }, 149 | { url = "https://files.pythonhosted.org/packages/3e/30/d41d3ba765609c0763505d565c4d12d8f3c79793f0d0f044ff5a28bf395b/psycopg2_binary-2.9.10-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:26540d4a9a4e2b096f1ff9cce51253d0504dca5a85872c7f7be23be5a53eb18d", size = 3044699, upload-time = "2024-10-16T11:21:42.841Z" }, 150 | { url = "https://files.pythonhosted.org/packages/35/44/257ddadec7ef04536ba71af6bc6a75ec05c5343004a7ec93006bee66c0bc/psycopg2_binary-2.9.10-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e217ce4d37667df0bc1c397fdcd8de5e81018ef305aed9415c3b093faaeb10fb", size = 3275245, upload-time = "2024-10-16T11:21:51.989Z" }, 151 | { url = "https://files.pythonhosted.org/packages/1b/11/48ea1cd11de67f9efd7262085588790a95d9dfcd9b8a687d46caf7305c1a/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:245159e7ab20a71d989da00f280ca57da7641fa2cdcf71749c193cea540a74f7", size = 2851631, upload-time = "2024-10-16T11:21:57.584Z" }, 152 | { url = "https://files.pythonhosted.org/packages/62/e0/62ce5ee650e6c86719d621a761fe4bc846ab9eff8c1f12b1ed5741bf1c9b/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c4ded1a24b20021ebe677b7b08ad10bf09aac197d6943bfe6fec70ac4e4690d", size = 3082140, upload-time = "2024-10-16T11:22:02.005Z" }, 153 | { url = "https://files.pythonhosted.org/packages/27/ce/63f946c098611f7be234c0dd7cb1ad68b0b5744d34f68062bb3c5aa510c8/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3abb691ff9e57d4a93355f60d4f4c1dd2d68326c968e7db17ea96df3c023ef73", size = 3264762, upload-time = "2024-10-16T11:22:06.412Z" }, 154 | { url = "https://files.pythonhosted.org/packages/43/25/c603cd81402e69edf7daa59b1602bd41eb9859e2824b8c0855d748366ac9/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8608c078134f0b3cbd9f89b34bd60a943b23fd33cc5f065e8d5f840061bd0673", size = 3020967, upload-time = "2024-10-16T11:22:11.583Z" }, 155 | { url = "https://files.pythonhosted.org/packages/5f/d6/8708d8c6fca531057fa170cdde8df870e8b6a9b136e82b361c65e42b841e/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:230eeae2d71594103cd5b93fd29d1ace6420d0b86f4778739cb1a5a32f607d1f", size = 2872326, upload-time = "2024-10-16T11:22:16.406Z" }, 156 | { url = "https://files.pythonhosted.org/packages/ce/ac/5b1ea50fc08a9df82de7e1771537557f07c2632231bbab652c7e22597908/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bb89f0a835bcfc1d42ccd5f41f04870c1b936d8507c6df12b7737febc40f0909", size = 2822712, upload-time = "2024-10-16T11:22:21.366Z" }, 157 | { url = "https://files.pythonhosted.org/packages/c4/fc/504d4503b2abc4570fac3ca56eb8fed5e437bf9c9ef13f36b6621db8ef00/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f0c2d907a1e102526dd2986df638343388b94c33860ff3bbe1384130828714b1", size = 2920155, upload-time = "2024-10-16T11:22:25.684Z" }, 158 | { url = "https://files.pythonhosted.org/packages/b2/d1/323581e9273ad2c0dbd1902f3fb50c441da86e894b6e25a73c3fda32c57e/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f8157bed2f51db683f31306aa497311b560f2265998122abe1dce6428bd86567", size = 2959356, upload-time = "2024-10-16T11:22:30.562Z" }, 159 | { url = "https://files.pythonhosted.org/packages/08/50/d13ea0a054189ae1bc21af1d85b6f8bb9bbc5572991055d70ad9006fe2d6/psycopg2_binary-2.9.10-cp313-cp313-win_amd64.whl", hash = "sha256:27422aa5f11fbcd9b18da48373eb67081243662f9b46e6fd07c3eb46e4535142", size = 2569224, upload-time = "2025-01-04T20:09:19.234Z" }, 160 | { url = "https://files.pythonhosted.org/packages/a2/bc/e77648009b6e61af327c607543f65fdf25bcfb4100f5a6f3bdb62ddac03c/psycopg2_binary-2.9.10-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:7a813c8bdbaaaab1f078014b9b0b13f5de757e2b5d9be6403639b298a04d218b", size = 3043437, upload-time = "2024-10-16T11:23:42.946Z" }, 161 | { url = "https://files.pythonhosted.org/packages/e0/e8/5a12211a1f5b959f3e3ccd342eace60c1f26422f53e06d687821dc268780/psycopg2_binary-2.9.10-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d00924255d7fc916ef66e4bf22f354a940c67179ad3fd7067d7a0a9c84d2fbfc", size = 2851340, upload-time = "2024-10-16T11:23:50.038Z" }, 162 | { url = "https://files.pythonhosted.org/packages/47/ed/5932b0458a7fc61237b653df050513c8d18a6f4083cc7f90dcef967f7bce/psycopg2_binary-2.9.10-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7559bce4b505762d737172556a4e6ea8a9998ecac1e39b5233465093e8cee697", size = 3080905, upload-time = "2024-10-16T11:23:57.932Z" }, 163 | { url = "https://files.pythonhosted.org/packages/71/df/8047d85c3d23864aca4613c3be1ea0fe61dbe4e050a89ac189f9dce4403e/psycopg2_binary-2.9.10-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8b58f0a96e7a1e341fc894f62c1177a7c83febebb5ff9123b579418fdc8a481", size = 3264640, upload-time = "2024-10-16T11:24:06.122Z" }, 164 | { url = "https://files.pythonhosted.org/packages/f3/de/6157e4ef242920e8f2749f7708d5cc8815414bdd4a27a91996e7cd5c80df/psycopg2_binary-2.9.10-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b269105e59ac96aba877c1707c600ae55711d9dcd3fc4b5012e4af68e30c648", size = 3019812, upload-time = "2024-10-16T11:24:17.025Z" }, 165 | { url = "https://files.pythonhosted.org/packages/25/f9/0fc49efd2d4d6db3a8d0a3f5749b33a0d3fdd872cad49fbf5bfce1c50027/psycopg2_binary-2.9.10-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:79625966e176dc97ddabc142351e0409e28acf4660b88d1cf6adb876d20c490d", size = 2871933, upload-time = "2024-10-16T11:24:24.858Z" }, 166 | { url = "https://files.pythonhosted.org/packages/57/bc/2ed1bd182219065692ed458d218d311b0b220b20662d25d913bc4e8d3549/psycopg2_binary-2.9.10-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:8aabf1c1a04584c168984ac678a668094d831f152859d06e055288fa515e4d30", size = 2820990, upload-time = "2024-10-16T11:24:29.571Z" }, 167 | { url = "https://files.pythonhosted.org/packages/71/2a/43f77a9b8ee0b10e2de784d97ddc099d9fe0d9eec462a006e4d2cc74756d/psycopg2_binary-2.9.10-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:19721ac03892001ee8fdd11507e6a2e01f4e37014def96379411ca99d78aeb2c", size = 2919352, upload-time = "2024-10-16T11:24:36.906Z" }, 168 | { url = "https://files.pythonhosted.org/packages/57/86/d2943df70469e6afab3b5b8e1367fccc61891f46de436b24ddee6f2c8404/psycopg2_binary-2.9.10-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7f5d859928e635fa3ce3477704acee0f667b3a3d3e4bb109f2b18d4005f38287", size = 2957614, upload-time = "2024-10-16T11:24:44.423Z" }, 169 | { url = "https://files.pythonhosted.org/packages/85/21/195d69371330983aa16139e60ba855d0a18164c9295f3a3696be41bbcd54/psycopg2_binary-2.9.10-cp39-cp39-win32.whl", hash = "sha256:3216ccf953b3f267691c90c6fe742e45d890d8272326b4a8b20850a03d05b7b8", size = 1025341, upload-time = "2024-10-16T11:24:48.056Z" }, 170 | { url = "https://files.pythonhosted.org/packages/ad/53/73196ebc19d6fbfc22427b982fbc98698b7b9c361e5e7707e3a3247cf06d/psycopg2_binary-2.9.10-cp39-cp39-win_amd64.whl", hash = "sha256:30e34c4e97964805f715206c7b789d54a78b70f3ff19fbe590104b71c45600e5", size = 1163958, upload-time = "2024-10-16T11:24:51.882Z" }, 171 | ] 172 | 173 | [[package]] 174 | name = "pytest" 175 | version = "8.3.5" 176 | source = { registry = "https://pypi.org/simple" } 177 | dependencies = [ 178 | { name = "colorama", marker = "sys_platform == 'win32'" }, 179 | { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, 180 | { name = "iniconfig" }, 181 | { name = "packaging" }, 182 | { name = "pluggy" }, 183 | { name = "tomli", marker = "python_full_version < '3.11'" }, 184 | ] 185 | sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891, upload-time = "2025-03-02T12:54:54.503Z" } 186 | wheels = [ 187 | { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634, upload-time = "2025-03-02T12:54:52.069Z" }, 188 | ] 189 | 190 | [[package]] 191 | name = "pytest-unmagic" 192 | version = "1.0.0" 193 | source = { registry = "https://pypi.org/simple" } 194 | dependencies = [ 195 | { name = "pytest" }, 196 | ] 197 | sdist = { url = "https://files.pythonhosted.org/packages/6e/77/b9c56b38fad9f2b6149bc4f6032f2899f04403f234abe5188b77b18c80e0/pytest_unmagic-1.0.0.tar.gz", hash = "sha256:52e5a6d2394a4feb84654e76f7ac0992ef925f80113de5297b9d1c3f84825fba", size = 10158, upload-time = "2024-10-22T19:10:25.126Z" } 198 | wheels = [ 199 | { url = "https://files.pythonhosted.org/packages/dc/15/d2ded304f9b62780045f823b8c0b99a9a1612dcfaaa5db4ec13ed566d0d2/pytest_unmagic-1.0.0-py3-none-any.whl", hash = "sha256:4da6eb3c5657ba4772a2c7992fa73e1eb1ad7e2f15defcadde39915be6c02a6f", size = 10754, upload-time = "2024-10-22T19:10:23.374Z" }, 200 | ] 201 | 202 | [[package]] 203 | name = "ruff" 204 | version = "0.11.11" 205 | source = { registry = "https://pypi.org/simple" } 206 | sdist = { url = "https://files.pythonhosted.org/packages/b2/53/ae4857030d59286924a8bdb30d213d6ff22d8f0957e738d0289990091dd8/ruff-0.11.11.tar.gz", hash = "sha256:7774173cc7c1980e6bf67569ebb7085989a78a103922fb83ef3dfe230cd0687d", size = 4186707, upload-time = "2025-05-22T19:19:34.363Z" } 207 | wheels = [ 208 | { url = "https://files.pythonhosted.org/packages/b1/14/f2326676197bab099e2a24473158c21656fbf6a207c65f596ae15acb32b9/ruff-0.11.11-py3-none-linux_armv6l.whl", hash = "sha256:9924e5ae54125ed8958a4f7de320dab7380f6e9fa3195e3dc3b137c6842a0092", size = 10229049, upload-time = "2025-05-22T19:18:45.516Z" }, 209 | { url = "https://files.pythonhosted.org/packages/9a/f3/bff7c92dd66c959e711688b2e0768e486bbca46b2f35ac319bb6cce04447/ruff-0.11.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:c8a93276393d91e952f790148eb226658dd275cddfde96c6ca304873f11d2ae4", size = 11053601, upload-time = "2025-05-22T19:18:49.269Z" }, 210 | { url = "https://files.pythonhosted.org/packages/e2/38/8e1a3efd0ef9d8259346f986b77de0f62c7a5ff4a76563b6b39b68f793b9/ruff-0.11.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d6e333dbe2e6ae84cdedefa943dfd6434753ad321764fd937eef9d6b62022bcd", size = 10367421, upload-time = "2025-05-22T19:18:51.754Z" }, 211 | { url = "https://files.pythonhosted.org/packages/b4/50/557ad9dd4fb9d0bf524ec83a090a3932d284d1a8b48b5906b13b72800e5f/ruff-0.11.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7885d9a5e4c77b24e8c88aba8c80be9255fa22ab326019dac2356cff42089fc6", size = 10581980, upload-time = "2025-05-22T19:18:54.011Z" }, 212 | { url = "https://files.pythonhosted.org/packages/c4/b2/e2ed82d6e2739ece94f1bdbbd1d81b712d3cdaf69f0a1d1f1a116b33f9ad/ruff-0.11.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1b5ab797fcc09121ed82e9b12b6f27e34859e4227080a42d090881be888755d4", size = 10089241, upload-time = "2025-05-22T19:18:56.041Z" }, 213 | { url = "https://files.pythonhosted.org/packages/3d/9f/b4539f037a5302c450d7c695c82f80e98e48d0d667ecc250e6bdeb49b5c3/ruff-0.11.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e231ff3132c1119ece836487a02785f099a43992b95c2f62847d29bace3c75ac", size = 11699398, upload-time = "2025-05-22T19:18:58.248Z" }, 214 | { url = "https://files.pythonhosted.org/packages/61/fb/32e029d2c0b17df65e6eaa5ce7aea5fbeaed22dddd9fcfbbf5fe37c6e44e/ruff-0.11.11-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:a97c9babe1d4081037a90289986925726b802d180cca784ac8da2bbbc335f709", size = 12427955, upload-time = "2025-05-22T19:19:00.981Z" }, 215 | { url = "https://files.pythonhosted.org/packages/6e/e3/160488dbb11f18c8121cfd588e38095ba779ae208292765972f7732bfd95/ruff-0.11.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8c4ddcbe8a19f59f57fd814b8b117d4fcea9bee7c0492e6cf5fdc22cfa563c8", size = 12069803, upload-time = "2025-05-22T19:19:03.258Z" }, 216 | { url = "https://files.pythonhosted.org/packages/ff/16/3b006a875f84b3d0bff24bef26b8b3591454903f6f754b3f0a318589dcc3/ruff-0.11.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6224076c344a7694c6fbbb70d4f2a7b730f6d47d2a9dc1e7f9d9bb583faf390b", size = 11242630, upload-time = "2025-05-22T19:19:05.871Z" }, 217 | { url = "https://files.pythonhosted.org/packages/65/0d/0338bb8ac0b97175c2d533e9c8cdc127166de7eb16d028a43c5ab9e75abd/ruff-0.11.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:882821fcdf7ae8db7a951df1903d9cb032bbe838852e5fc3c2b6c3ab54e39875", size = 11507310, upload-time = "2025-05-22T19:19:08.584Z" }, 218 | { url = "https://files.pythonhosted.org/packages/6f/bf/d7130eb26174ce9b02348b9f86d5874eafbf9f68e5152e15e8e0a392e4a3/ruff-0.11.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:dcec2d50756463d9df075a26a85a6affbc1b0148873da3997286caf1ce03cae1", size = 10441144, upload-time = "2025-05-22T19:19:13.621Z" }, 219 | { url = "https://files.pythonhosted.org/packages/b3/f3/4be2453b258c092ff7b1761987cf0749e70ca1340cd1bfb4def08a70e8d8/ruff-0.11.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:99c28505ecbaeb6594701a74e395b187ee083ee26478c1a795d35084d53ebd81", size = 10081987, upload-time = "2025-05-22T19:19:15.821Z" }, 220 | { url = "https://files.pythonhosted.org/packages/6c/6e/dfa4d2030c5b5c13db158219f2ec67bf333e8a7748dccf34cfa2a6ab9ebc/ruff-0.11.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9263f9e5aa4ff1dec765e99810f1cc53f0c868c5329b69f13845f699fe74f639", size = 11073922, upload-time = "2025-05-22T19:19:18.104Z" }, 221 | { url = "https://files.pythonhosted.org/packages/ff/f4/f7b0b0c3d32b593a20ed8010fa2c1a01f2ce91e79dda6119fcc51d26c67b/ruff-0.11.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:64ac6f885e3ecb2fdbb71de2701d4e34526651f1e8503af8fb30d4915a3fe345", size = 11568537, upload-time = "2025-05-22T19:19:20.889Z" }, 222 | { url = "https://files.pythonhosted.org/packages/d2/46/0e892064d0adc18bcc81deed9aaa9942a27fd2cd9b1b7791111ce468c25f/ruff-0.11.11-py3-none-win32.whl", hash = "sha256:1adcb9a18802268aaa891ffb67b1c94cd70578f126637118e8099b8e4adcf112", size = 10536492, upload-time = "2025-05-22T19:19:23.642Z" }, 223 | { url = "https://files.pythonhosted.org/packages/1b/d9/232e79459850b9f327e9f1dc9c047a2a38a6f9689e1ec30024841fc4416c/ruff-0.11.11-py3-none-win_amd64.whl", hash = "sha256:748b4bb245f11e91a04a4ff0f96e386711df0a30412b9fe0c74d5bdc0e4a531f", size = 11612562, upload-time = "2025-05-22T19:19:27.013Z" }, 224 | { url = "https://files.pythonhosted.org/packages/ce/eb/09c132cff3cc30b2e7244191dcce69437352d6d6709c0adf374f3e6f476e/ruff-0.11.11-py3-none-win_arm64.whl", hash = "sha256:6c51f136c0364ab1b774767aa8b86331bd8e9d414e2d107db7a2189f35ea1f7b", size = 10735951, upload-time = "2025-05-22T19:19:30.043Z" }, 225 | ] 226 | 227 | [[package]] 228 | name = "sqlparse" 229 | version = "0.5.3" 230 | source = { registry = "https://pypi.org/simple" } 231 | sdist = { url = "https://files.pythonhosted.org/packages/e5/40/edede8dd6977b0d3da179a342c198ed100dd2aba4be081861ee5911e4da4/sqlparse-0.5.3.tar.gz", hash = "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272", size = 84999, upload-time = "2024-12-10T12:05:30.728Z" } 232 | wheels = [ 233 | { url = "https://files.pythonhosted.org/packages/a9/5c/bfd6bd0bf979426d405cc6e71eceb8701b148b16c21d2dc3c261efc61c7b/sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca", size = 44415, upload-time = "2024-12-10T12:05:27.824Z" }, 234 | ] 235 | 236 | [[package]] 237 | name = "tomli" 238 | version = "2.2.1" 239 | source = { registry = "https://pypi.org/simple" } 240 | sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175, upload-time = "2024-11-27T22:38:36.873Z" } 241 | wheels = [ 242 | { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077, upload-time = "2024-11-27T22:37:54.956Z" }, 243 | { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429, upload-time = "2024-11-27T22:37:56.698Z" }, 244 | { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067, upload-time = "2024-11-27T22:37:57.63Z" }, 245 | { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030, upload-time = "2024-11-27T22:37:59.344Z" }, 246 | { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898, upload-time = "2024-11-27T22:38:00.429Z" }, 247 | { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894, upload-time = "2024-11-27T22:38:02.094Z" }, 248 | { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319, upload-time = "2024-11-27T22:38:03.206Z" }, 249 | { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273, upload-time = "2024-11-27T22:38:04.217Z" }, 250 | { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310, upload-time = "2024-11-27T22:38:05.908Z" }, 251 | { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309, upload-time = "2024-11-27T22:38:06.812Z" }, 252 | { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762, upload-time = "2024-11-27T22:38:07.731Z" }, 253 | { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453, upload-time = "2024-11-27T22:38:09.384Z" }, 254 | { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486, upload-time = "2024-11-27T22:38:10.329Z" }, 255 | { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349, upload-time = "2024-11-27T22:38:11.443Z" }, 256 | { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159, upload-time = "2024-11-27T22:38:13.099Z" }, 257 | { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243, upload-time = "2024-11-27T22:38:14.766Z" }, 258 | { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645, upload-time = "2024-11-27T22:38:15.843Z" }, 259 | { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584, upload-time = "2024-11-27T22:38:17.645Z" }, 260 | { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875, upload-time = "2024-11-27T22:38:19.159Z" }, 261 | { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418, upload-time = "2024-11-27T22:38:20.064Z" }, 262 | { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708, upload-time = "2024-11-27T22:38:21.659Z" }, 263 | { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582, upload-time = "2024-11-27T22:38:22.693Z" }, 264 | { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543, upload-time = "2024-11-27T22:38:24.367Z" }, 265 | { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691, upload-time = "2024-11-27T22:38:26.081Z" }, 266 | { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170, upload-time = "2024-11-27T22:38:27.921Z" }, 267 | { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530, upload-time = "2024-11-27T22:38:29.591Z" }, 268 | { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666, upload-time = "2024-11-27T22:38:30.639Z" }, 269 | { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954, upload-time = "2024-11-27T22:38:31.702Z" }, 270 | { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724, upload-time = "2024-11-27T22:38:32.837Z" }, 271 | { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383, upload-time = "2024-11-27T22:38:34.455Z" }, 272 | { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, 273 | ] 274 | 275 | [[package]] 276 | name = "typing-extensions" 277 | version = "4.13.2" 278 | source = { registry = "https://pypi.org/simple" } 279 | sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967, upload-time = "2025-04-10T14:19:05.416Z" } 280 | wheels = [ 281 | { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806, upload-time = "2025-04-10T14:19:03.967Z" }, 282 | ] 283 | 284 | [[package]] 285 | name = "tzdata" 286 | version = "2025.2" 287 | source = { registry = "https://pypi.org/simple" } 288 | sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" } 289 | wheels = [ 290 | { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, 291 | ] 292 | --------------------------------------------------------------------------------