├── .github └── workflows │ ├── pypi.yml │ └── tests.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── django_cte ├── __init__.py ├── cte.py ├── expressions.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 └── 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 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: Check style 90 | run: .venv/bin/ruff check 91 | -------------------------------------------------------------------------------- /.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 CTEManager, CTEQuerySet, With # noqa 2 | 3 | __version__ = "1.3.3" 4 | -------------------------------------------------------------------------------- /django_cte/cte.py: -------------------------------------------------------------------------------- 1 | from django.db.models import Manager 2 | from django.db.models.expressions import Ref 3 | from django.db.models.query import Q, QuerySet, ValuesIterable 4 | from django.db.models.sql.datastructures import BaseTable 5 | 6 | from .join import QJoin, INNER 7 | from .meta import CTEColumnRef, CTEColumns 8 | from .query import CTEQuery 9 | 10 | __all__ = ["With", "CTEManager", "CTEQuerySet"] 11 | 12 | 13 | class With(object): 14 | """Common Table Expression query object: `WITH ...` 15 | 16 | :param queryset: A queryset to use as the body of the CTE. 17 | :param name: Optional name parameter for the CTE (default: "cte"). 18 | This must be a unique name that does not conflict with other 19 | entities (tables, views, functions, other CTE(s), etc.) referenced 20 | in the given query as well any query to which this CTE will 21 | eventually be added. 22 | :param materialized: Optional parameter (default: False) which enforce 23 | using of MATERIALIZED statement for supporting databases. 24 | """ 25 | 26 | def __init__(self, queryset, name="cte", materialized=False): 27 | self.query = None if queryset is None else queryset.query 28 | self.name = name 29 | self.col = CTEColumns(self) 30 | self.materialized = materialized 31 | 32 | def __getstate__(self): 33 | return (self.query, self.name, self.materialized) 34 | 35 | def __setstate__(self, state): 36 | self.query, self.name, self.materialized = state 37 | self.col = CTEColumns(self) 38 | 39 | def __repr__(self): 40 | return "".format(self.name) 41 | 42 | @classmethod 43 | def recursive(cls, make_cte_queryset, name="cte", materialized=False): 44 | """Recursive Common Table Expression: `WITH RECURSIVE ...` 45 | 46 | :param make_cte_queryset: Function taking a single argument (a 47 | not-yet-fully-constructed cte object) and returning a `QuerySet` 48 | object. The returned `QuerySet` normally consists of an initial 49 | statement unioned with a recursive statement. 50 | :param name: See `name` parameter of `__init__`. 51 | :param materialized: See `materialized` parameter of `__init__`. 52 | :returns: The fully constructed recursive cte object. 53 | """ 54 | cte = cls(None, name, materialized) 55 | cte.query = make_cte_queryset(cte).query 56 | return cte 57 | 58 | def join(self, model_or_queryset, *filter_q, **filter_kw): 59 | """Join this CTE to the given model or queryset 60 | 61 | This CTE will be refernced by the returned queryset, but the 62 | corresponding `WITH ...` statement will not be prepended to the 63 | queryset's SQL output; use `.with_cte(cte)` to 64 | achieve that outcome. 65 | 66 | :param model_or_queryset: Model class or queryset to which the 67 | CTE should be joined. 68 | :param *filter_q: Join condition Q expressions (optional). 69 | :param **filter_kw: Join conditions. All LHS fields (kwarg keys) 70 | are assumed to reference `model_or_queryset` fields. Use 71 | `cte.col.name` on the RHS to recursively reference CTE query 72 | columns. For example: `cte.join(Book, id=cte.col.id)` 73 | :returns: A queryset with the given model or queryset joined to 74 | this CTE. 75 | """ 76 | if isinstance(model_or_queryset, QuerySet): 77 | queryset = model_or_queryset.all() 78 | else: 79 | queryset = model_or_queryset._default_manager.all() 80 | join_type = filter_kw.pop("_join_type", INNER) 81 | query = queryset.query 82 | 83 | # based on Query.add_q: add necessary joins to query, but no filter 84 | q_object = Q(*filter_q, **filter_kw) 85 | map = query.alias_map 86 | existing_inner = set(a for a in map if map[a].join_type == INNER) 87 | on_clause, _ = query._add_q(q_object, query.used_aliases) 88 | query.demote_joins(existing_inner) 89 | 90 | parent = query.get_initial_alias() 91 | query.join(QJoin(parent, self.name, self.name, on_clause, join_type)) 92 | return queryset 93 | 94 | def queryset(self): 95 | """Get a queryset selecting from this CTE 96 | 97 | This CTE will be referenced by the returned queryset, but the 98 | corresponding `WITH ...` statement will not be prepended to the 99 | queryset's SQL output; use `.with_cte(cte)` to 100 | achieve that outcome. 101 | 102 | :returns: A queryset. 103 | """ 104 | cte_query = self.query 105 | qs = cte_query.model._default_manager.get_queryset() 106 | 107 | query = CTEQuery(cte_query.model) 108 | query.join(BaseTable(self.name, None)) 109 | query.default_cols = cte_query.default_cols 110 | query.deferred_loading = cte_query.deferred_loading 111 | if cte_query.values_select: 112 | query.set_values(cte_query.values_select) 113 | qs._iterable_class = ValuesIterable 114 | for alias in getattr(cte_query, "selected", None) or (): 115 | if alias not in cte_query.annotations: 116 | col = Ref(alias, cte_query.resolve_ref(alias)) 117 | query.add_annotation(col, alias) 118 | if cte_query.annotations: 119 | for alias, value in cte_query.annotations.items(): 120 | col = CTEColumnRef(alias, self.name, value.output_field) 121 | query.add_annotation(col, alias) 122 | query.annotation_select_mask = cte_query.annotation_select_mask 123 | 124 | qs.query = query 125 | return qs 126 | 127 | def _resolve_ref(self, name): 128 | selected = getattr(self.query, "selected", None) 129 | if selected and name in selected and name not in self.query.annotations: 130 | return Ref(name, self.query.resolve_ref(name)) 131 | return self.query.resolve_ref(name) 132 | 133 | 134 | class CTEQuerySet(QuerySet): 135 | """QuerySet with support for Common Table Expressions""" 136 | 137 | def __init__(self, model=None, query=None, using=None, hints=None): 138 | # Only create an instance of a Query if this is the first invocation in 139 | # a query chain. 140 | if query is None: 141 | query = CTEQuery(model) 142 | super(CTEQuerySet, self).__init__(model, query, using, hints) 143 | 144 | def with_cte(self, cte): 145 | """Add a Common Table Expression to this queryset 146 | 147 | The CTE `WITH ...` clause will be added to the queryset's SQL 148 | output (after other CTEs that have already been added) so it 149 | can be referenced in annotations, filters, etc. 150 | """ 151 | qs = self._clone() 152 | qs.query._with_ctes.append(cte) 153 | return qs 154 | 155 | def as_manager(cls): 156 | # Address the circular dependency between 157 | # `CTEQuerySet` and `CTEManager`. 158 | manager = CTEManager.from_queryset(cls)() 159 | manager._built_with_as_manager = True 160 | return manager 161 | as_manager.queryset_only = True 162 | as_manager = classmethod(as_manager) 163 | 164 | def _combinator_query(self, *args, **kw): 165 | clone = super()._combinator_query(*args, **kw) 166 | if clone.query.combinator: 167 | ctes = clone.query._with_ctes = [] 168 | seen = {} 169 | for query in clone.query.combined_queries: 170 | for cte in getattr(query, "_with_ctes", []): 171 | if seen.get(cte.name) is cte: 172 | continue 173 | if cte.name in seen: 174 | raise ValueError( 175 | f"Found two or more CTEs named '{cte.name}'. " 176 | "Hint: assign a unique name to each CTE." 177 | ) 178 | ctes.append(cte) 179 | seen[cte.name] = cte 180 | if ctes: 181 | def without_ctes(query): 182 | if getattr(query, "_with_ctes", None): 183 | query = query.clone() 184 | query._with_ctes = [] 185 | return query 186 | 187 | clone.query.combined_queries = [ 188 | without_ctes(query) 189 | for query in clone.query.combined_queries 190 | ] 191 | return clone 192 | 193 | 194 | class CTEManager(Manager.from_queryset(CTEQuerySet)): 195 | """Manager for models that perform CTE queries""" 196 | 197 | @classmethod 198 | def from_queryset(cls, queryset_class, class_name=None): 199 | if not issubclass(queryset_class, CTEQuerySet): 200 | raise TypeError( 201 | "models with CTE support need to use a CTEQuerySet") 202 | return super(CTEManager, cls).from_queryset( 203 | queryset_class, class_name=class_name) 204 | -------------------------------------------------------------------------------- /django_cte/expressions.py: -------------------------------------------------------------------------------- 1 | from django.db.models import Subquery 2 | 3 | 4 | class CTESubqueryResolver(object): 5 | 6 | def __init__(self, annotation): 7 | self.annotation = annotation 8 | 9 | def resolve_expression(self, *args, **kw): 10 | # source: django.db.models.expressions.Subquery.resolve_expression 11 | # --- begin copied code (lightly adapted) --- # 12 | 13 | # Need to recursively resolve these. 14 | def resolve_all(child): 15 | if hasattr(child, 'children'): 16 | [resolve_all(_child) for _child in child.children] 17 | if hasattr(child, 'rhs'): 18 | child.rhs = resolve(child.rhs) 19 | 20 | def resolve(child): 21 | if hasattr(child, 'resolve_expression'): 22 | resolved = child.resolve_expression(*args, **kw) 23 | # Add table alias to the parent query's aliases to prevent 24 | # quoting. 25 | if hasattr(resolved, 'alias') and \ 26 | resolved.alias != resolved.target.model._meta.db_table: 27 | get_query(clone).external_aliases.add(resolved.alias) 28 | return resolved 29 | return child 30 | 31 | # --- end copied code --- # 32 | 33 | def get_query(clone): 34 | return clone.query 35 | 36 | # NOTE this uses the old (pre-Django 3) way of resolving. 37 | # Should a different technique should be used on Django 3+? 38 | clone = self.annotation.resolve_expression(*args, **kw) 39 | if isinstance(self.annotation, Subquery): 40 | for cte in getattr(get_query(clone), '_with_ctes', []): 41 | resolve_all(cte.query.where) 42 | for key, value in cte.query.annotations.items(): 43 | if isinstance(value, Subquery): 44 | cte.query.annotations[key] = resolve(value) 45 | return clone 46 | -------------------------------------------------------------------------------- /django_cte/join.py: -------------------------------------------------------------------------------- 1 | from django.db.models.sql.constants import INNER 2 | 3 | 4 | class QJoin(object): 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(object): 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(CTEColumnRef, self).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(CTEColumnRef, self).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 import connections 4 | from django.db.models.sql import DeleteQuery, Query, UpdateQuery 5 | from django.db.models.sql.compiler import ( 6 | SQLCompiler, 7 | SQLDeleteCompiler, 8 | SQLUpdateCompiler, 9 | ) 10 | from django.db.models.sql.constants import LOUTER 11 | 12 | from .expressions import CTESubqueryResolver 13 | from .join import QJoin 14 | 15 | 16 | class CTEQuery(Query): 17 | """A Query which processes SQL compilation through the CTE compiler""" 18 | 19 | def __init__(self, *args, **kwargs): 20 | super(CTEQuery, self).__init__(*args, **kwargs) 21 | self._with_ctes = [] 22 | 23 | def combine(self, other, connector): 24 | if other._with_ctes: 25 | if self._with_ctes: 26 | raise TypeError("cannot merge queries with CTEs on both sides") 27 | self._with_ctes = other._with_ctes[:] 28 | return super(CTEQuery, self).combine(other, connector) 29 | 30 | def get_compiler(self, using=None, connection=None, *args, **kwargs): 31 | """ Overrides the Query method get_compiler in order to return 32 | a CTECompiler. 33 | """ 34 | # Copy the body of this method from Django except the final 35 | # return statement. We will ignore code coverage for this. 36 | if using is None and connection is None: # pragma: no cover 37 | raise ValueError("Need either using or connection") 38 | if using: 39 | connection = connections[using] 40 | # Check that the compiler will be able to execute the query 41 | for alias, aggregate in self.annotation_select.items(): 42 | connection.ops.check_expression_support(aggregate) 43 | # Instantiate the custom compiler. 44 | klass = COMPILER_TYPES.get(self.__class__, CTEQueryCompiler) 45 | return klass(self, connection, using, *args, **kwargs) 46 | 47 | def add_annotation(self, annotation, *args, **kw): 48 | annotation = CTESubqueryResolver(annotation) 49 | super(CTEQuery, self).add_annotation(annotation, *args, **kw) 50 | 51 | def __chain(self, _name, klass=None, *args, **kwargs): 52 | klass = QUERY_TYPES.get(klass, self.__class__) 53 | clone = getattr(super(CTEQuery, self), _name)(klass, *args, **kwargs) 54 | clone._with_ctes = self._with_ctes[:] 55 | return clone 56 | 57 | def chain(self, klass=None): 58 | return self.__chain("chain", klass) 59 | 60 | 61 | class CTECompiler(object): 62 | 63 | @classmethod 64 | def generate_sql(cls, connection, query, as_sql): 65 | if not query._with_ctes: 66 | return as_sql() 67 | 68 | ctes = [] 69 | params = [] 70 | for cte in query._with_ctes: 71 | if django.VERSION > (4, 2): 72 | _ignore_with_col_aliases(cte.query) 73 | 74 | alias = query.alias_map.get(cte.name) 75 | should_elide_empty = ( 76 | not isinstance(alias, QJoin) or alias.join_type != LOUTER 77 | ) 78 | 79 | compiler = cte.query.get_compiler( 80 | connection=connection, elide_empty=should_elide_empty 81 | ) 82 | 83 | qn = compiler.quote_name_unless_alias 84 | try: 85 | cte_sql, cte_params = compiler.as_sql() 86 | except EmptyResultSet: 87 | # If the CTE raises an EmptyResultSet the SqlCompiler still 88 | # needs to know the information about this base compiler 89 | # like, col_count and klass_info. 90 | as_sql() 91 | raise 92 | template = cls.get_cte_query_template(cte) 93 | ctes.append(template.format(name=qn(cte.name), query=cte_sql)) 94 | params.extend(cte_params) 95 | 96 | explain_attribute = "explain_info" 97 | explain_info = getattr(query, explain_attribute, None) 98 | explain_format = getattr(explain_info, "format", None) 99 | explain_options = getattr(explain_info, "options", {}) 100 | 101 | explain_query_or_info = getattr(query, explain_attribute, None) 102 | sql = [] 103 | if explain_query_or_info: 104 | sql.append( 105 | connection.ops.explain_query_prefix( 106 | explain_format, 107 | **explain_options 108 | ) 109 | ) 110 | # this needs to get set to None so that the base as_sql() doesn't 111 | # insert the EXPLAIN statement where it would end up between the 112 | # WITH ... clause and the final SELECT 113 | setattr(query, explain_attribute, None) 114 | 115 | if ctes: 116 | # Always use WITH RECURSIVE 117 | # https://www.postgresql.org/message-id/13122.1339829536%40sss.pgh.pa.us 118 | sql.extend(["WITH RECURSIVE", ", ".join(ctes)]) 119 | base_sql, base_params = as_sql() 120 | 121 | if explain_query_or_info: 122 | setattr(query, explain_attribute, explain_query_or_info) 123 | 124 | sql.append(base_sql) 125 | params.extend(base_params) 126 | return " ".join(sql), tuple(params) 127 | 128 | @classmethod 129 | def get_cte_query_template(cls, cte): 130 | if cte.materialized: 131 | return "{name} AS MATERIALIZED ({query})" 132 | return "{name} AS ({query})" 133 | 134 | 135 | class CTEUpdateQuery(UpdateQuery, CTEQuery): 136 | pass 137 | 138 | 139 | class CTEDeleteQuery(DeleteQuery, CTEQuery): 140 | pass 141 | 142 | 143 | QUERY_TYPES = { 144 | Query: CTEQuery, 145 | UpdateQuery: CTEUpdateQuery, 146 | DeleteQuery: CTEDeleteQuery, 147 | } 148 | 149 | 150 | def _ignore_with_col_aliases(cte_query): 151 | if getattr(cte_query, "combined_queries", None): 152 | for query in cte_query.combined_queries: 153 | query.ignore_with_col_aliases = True 154 | 155 | 156 | class CTEQueryCompiler(SQLCompiler): 157 | 158 | def as_sql(self, *args, **kwargs): 159 | def _as_sql(): 160 | return super(CTEQueryCompiler, self).as_sql(*args, **kwargs) 161 | return CTECompiler.generate_sql(self.connection, self.query, _as_sql) 162 | 163 | def get_select(self, **kw): 164 | if kw.get("with_col_aliases") \ 165 | and getattr(self.query, "ignore_with_col_aliases", False): 166 | kw.pop("with_col_aliases") 167 | return super().get_select(**kw) 168 | 169 | 170 | class CTEUpdateQueryCompiler(SQLUpdateCompiler): 171 | 172 | def as_sql(self, *args, **kwargs): 173 | def _as_sql(): 174 | return super(CTEUpdateQueryCompiler, self).as_sql(*args, **kwargs) 175 | return CTECompiler.generate_sql(self.connection, self.query, _as_sql) 176 | 177 | 178 | class CTEDeleteQueryCompiler(SQLDeleteCompiler): 179 | 180 | # NOTE: it is currently not possible to execute delete queries that 181 | # reference CTEs without patching `QuerySet.delete` (Django method) 182 | # to call `self.query.chain(sql.DeleteQuery)` instead of 183 | # `sql.DeleteQuery(self.model)` 184 | 185 | def as_sql(self, *args, **kwargs): 186 | def _as_sql(): 187 | return super(CTEDeleteQueryCompiler, self).as_sql(*args, **kwargs) 188 | return CTECompiler.generate_sql(self.connection, self.query, _as_sql) 189 | 190 | 191 | COMPILER_TYPES = { 192 | CTEUpdateQuery: CTEUpdateQueryCompiler, 193 | CTEDeleteQuery: CTEDeleteQueryCompiler, 194 | } 195 | -------------------------------------------------------------------------------- /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(object): 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(object): 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(object): 29 | class query(object): 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 | ## Prerequisite: A Model with a "CTEManager" 12 | 13 | The custom manager class, `CTEManager`, constructs `CTEQuerySet`s, which have 14 | all of the same features as normal `QuerySet`s and also support CTE queries. 15 | 16 | ```py 17 | from django_cte import CTEManager 18 | 19 | class Order(Model): 20 | objects = CTEManager() 21 | id = AutoField(primary_key=True) 22 | region = ForeignKey("Region", on_delete=CASCADE) 23 | amount = IntegerField(default=0) 24 | 25 | class Meta: 26 | db_table = "orders" 27 | ``` 28 | 29 | 30 | ## Simple Common Table Expressions 31 | 32 | Simple CTEs are constructed using `With(...)`. A CTE can be joined to a model or 33 | other `CTEQuerySet` using its `join(...)` method, which creates a new queryset 34 | with a `JOIN` and `ON` condition. Finally, the CTE is added to the resulting 35 | queryset using `with_cte(cte)`, which adds the `WITH` expression before the 36 | main `SELECT` query. 37 | 38 | ```py 39 | from django_cte import With 40 | 41 | cte = With( 42 | Order.objects 43 | .values("region_id") 44 | .annotate(total=Sum("amount")) 45 | ) 46 | 47 | orders = ( 48 | # FROM orders INNER JOIN cte ON orders.region_id = cte.region_id 49 | cte.join(Order, region=cte.col.region_id) 50 | 51 | # Add `WITH ...` before `SELECT ... FROM orders ...` 52 | .with_cte(cte) 53 | 54 | # Annotate each Order with a "region_total" 55 | .annotate(region_total=cte.col.total) 56 | ) 57 | 58 | print(orders.query) # print SQL 59 | ``` 60 | 61 | The `orders` SQL, after formatting for readability, would look something like 62 | this: 63 | 64 | ```sql 65 | WITH RECURSIVE "cte" AS ( 66 | SELECT 67 | "orders"."region_id", 68 | SUM("orders"."amount") AS "total" 69 | FROM "orders" 70 | GROUP BY "orders"."region_id" 71 | ) 72 | SELECT 73 | "orders"."id", 74 | "orders"."region_id", 75 | "orders"."amount", 76 | "cte"."total" AS "region_total" 77 | FROM "orders" 78 | INNER JOIN "cte" ON "orders"."region_id" = "cte"."region_id" 79 | ``` 80 | 81 | The `orders` query is a query set containing annotated `Order` objects, just as 82 | you would get from a query like `Order.objects.annotate(region_total=...)`. Each 83 | `Order` object will be annotated with a `region_total` attribute, which is 84 | populated with the value of the corresponding total from the joined CTE query. 85 | 86 | You may have noticed the CTE in this query uses `WITH RECURSIVE` even though 87 | this is not a [Recursive Common Table Expression](#recursive-common-table-expressions). 88 | The `RECURSIVE` keyword is always used, even for non-recursive CTEs. On 89 | databases such as PostgreSQL and SQLite this has no effect other than allowing 90 | recursive CTEs to be included in the WITH block. 91 | 92 | 93 | ## Recursive Common Table Expressions 94 | 95 | Recursive CTE queries allow fundamentally new types of queries that are 96 | not otherwise possible. First, a model for the example. 97 | 98 | ```py 99 | class Region(Model): 100 | objects = CTEManager() 101 | name = TextField(primary_key=True) 102 | parent = ForeignKey("self", null=True, on_delete=CASCADE) 103 | 104 | class Meta: 105 | db_table = "region" 106 | ``` 107 | 108 | Recursive CTEs are constructed using `With.recursive()`, which takes as its 109 | first argument a function that constructs and returns a recursive query. 110 | Recursive queries have two elements: first a non-recursive query element, and 111 | second a recursive query element. The second is typically attached to the first 112 | using `QuerySet.union()`. 113 | 114 | ```py 115 | def make_regions_cte(cte): 116 | # non-recursive: get root nodes 117 | return Region.objects.filter( 118 | parent__isnull=True 119 | ).values( 120 | "name", 121 | path=F("name"), 122 | depth=Value(0, output_field=IntegerField()), 123 | ).union( 124 | # recursive union: get descendants 125 | cte.join(Region, parent=cte.col.name).values( 126 | "name", 127 | path=Concat( 128 | cte.col.path, Value(" / "), F("name"), 129 | output_field=TextField(), 130 | ), 131 | depth=cte.col.depth + Value(1, output_field=IntegerField()), 132 | ), 133 | all=True, 134 | ) 135 | 136 | cte = With.recursive(make_regions_cte) 137 | 138 | regions = ( 139 | cte.join(Region, name=cte.col.name) 140 | .with_cte(cte) 141 | .annotate( 142 | path=cte.col.path, 143 | depth=cte.col.depth, 144 | ) 145 | .filter(depth=2) 146 | .order_by("path") 147 | ) 148 | ``` 149 | 150 | `Region` objects returned by this query will have `path` and `depth` attributes. 151 | The results will be ordered by `path` (hierarchically by region name). The SQL 152 | produced by this query looks something like this: 153 | 154 | ```sql 155 | WITH RECURSIVE "cte" AS ( 156 | SELECT 157 | "region"."name", 158 | "region"."name" AS "path", 159 | 0 AS "depth" 160 | FROM "region" 161 | WHERE "region"."parent_id" IS NULL 162 | 163 | UNION ALL 164 | 165 | SELECT 166 | "region"."name", 167 | "cte"."path" || ' / ' || "region"."name" AS "path", 168 | "cte"."depth" + 1 AS "depth" 169 | FROM "region" 170 | INNER JOIN "cte" ON "region"."parent_id" = "cte"."name" 171 | ) 172 | SELECT 173 | "region"."name", 174 | "region"."parent_id", 175 | "cte"."path" AS "path", 176 | "cte"."depth" AS "depth" 177 | FROM "region" 178 | INNER JOIN "cte" ON "region"."name" = "cte"."name" 179 | WHERE "cte"."depth" = 2 180 | ORDER BY "path" ASC 181 | ``` 182 | 183 | 184 | ## Named Common Table Expressions 185 | 186 | It is possible to add more than one CTE to a query. To do this, each CTE must 187 | have a unique name. `With(queryset)` returns a CTE with the name `'cte'` by 188 | default, but that can be overridden: `With(queryset, name='custom')` or 189 | `With.recursive(make_queryset, name='custom')`. This allows each CTE to be 190 | referenced uniquely within a single query. 191 | 192 | Also note that a CTE may reference other CTEs in the same query. 193 | 194 | Example query with two CTEs, and the second (`totals`) CTE references the first 195 | (`rootmap`): 196 | 197 | ```py 198 | def make_root_mapping(rootmap): 199 | return Region.objects.filter( 200 | parent__isnull=True 201 | ).values( 202 | "name", 203 | root=F("name"), 204 | ).union( 205 | rootmap.join(Region, parent=rootmap.col.name).values( 206 | "name", 207 | root=rootmap.col.root, 208 | ), 209 | all=True, 210 | ) 211 | rootmap = With.recursive(make_root_mapping, name="rootmap") 212 | 213 | totals = With( 214 | rootmap.join(Order, region_id=rootmap.col.name) 215 | .values( 216 | root=rootmap.col.root, 217 | ).annotate( 218 | orders_count=Count("id"), 219 | region_total=Sum("amount"), 220 | ), 221 | name="totals", 222 | ) 223 | 224 | root_regions = ( 225 | totals.join(Region, name=totals.col.root) 226 | # Important: add both CTEs to the final query 227 | .with_cte(rootmap) 228 | .with_cte(totals) 229 | .annotate( 230 | # count of orders in this region and all subregions 231 | orders_count=totals.col.orders_count, 232 | # sum of order amounts in this region and all subregions 233 | region_total=totals.col.region_total, 234 | ) 235 | ) 236 | ``` 237 | 238 | And the resulting SQL. 239 | 240 | ```sql 241 | WITH RECURSIVE "rootmap" AS ( 242 | SELECT 243 | "region"."name", 244 | "region"."name" AS "root" 245 | FROM "region" 246 | WHERE "region"."parent_id" IS NULL 247 | 248 | UNION ALL 249 | 250 | SELECT 251 | "region"."name", 252 | "rootmap"."root" AS "root" 253 | FROM "region" 254 | INNER JOIN "rootmap" ON "region"."parent_id" = "rootmap"."name" 255 | ), 256 | "totals" AS ( 257 | SELECT 258 | "rootmap"."root" AS "root", 259 | COUNT("orders"."id") AS "orders_count", 260 | SUM("orders"."amount") AS "region_total" 261 | FROM "orders" 262 | INNER JOIN "rootmap" ON "orders"."region_id" = "rootmap"."name" 263 | GROUP BY "rootmap"."root" 264 | ) 265 | SELECT 266 | "region"."name", 267 | "region"."parent_id", 268 | "totals"."orders_count" AS "orders_count", 269 | "totals"."region_total" AS "region_total" 270 | FROM "region" 271 | INNER JOIN "totals" ON "region"."name" = "totals"."root" 272 | ``` 273 | 274 | 275 | ## Selecting FROM a Common Table Expression 276 | 277 | Sometimes it is useful to construct queries where the final `FROM` clause 278 | contains only common table expression(s). This is possible with 279 | `With(...).queryset()`. 280 | 281 | Each returned row may be a model object: 282 | 283 | ```py 284 | cte = With( 285 | Order.objects 286 | .annotate(region_parent=F("region__parent_id")), 287 | ) 288 | orders = cte.queryset().with_cte(cte) 289 | ``` 290 | 291 | And the resulting SQL: 292 | 293 | ```sql 294 | WITH RECURSIVE "cte" AS ( 295 | SELECT 296 | "orders"."id", 297 | "orders"."region_id", 298 | "orders"."amount", 299 | "region"."parent_id" AS "region_parent" 300 | FROM "orders" 301 | INNER JOIN "region" ON "orders"."region_id" = "region"."name" 302 | ) 303 | SELECT 304 | "cte"."id", 305 | "cte"."region_id", 306 | "cte"."amount", 307 | "cte"."region_parent" AS "region_parent" 308 | FROM "cte" 309 | ``` 310 | 311 | It is also possible to do the same with `values(...)` queries: 312 | 313 | ```py 314 | cte = With( 315 | Order.objects 316 | .values( 317 | "region_id", 318 | region_parent=F("region__parent_id"), 319 | ) 320 | .distinct() 321 | ) 322 | values = cte.queryset().with_cte(cte).filter(region_parent__isnull=False) 323 | ``` 324 | 325 | Which produces this SQL: 326 | 327 | ```sql 328 | WITH RECURSIVE "cte" AS ( 329 | SELECT DISTINCT 330 | "orders"."region_id", 331 | "region"."parent_id" AS "region_parent" 332 | FROM "orders" 333 | INNER JOIN "region" ON "orders"."region_id" = "region"."name" 334 | ) 335 | SELECT 336 | "cte"."region_id", 337 | "cte"."region_parent" AS "region_parent" 338 | FROM "cte" 339 | WHERE "cte"."region_parent" IS NOT NULL 340 | ``` 341 | 342 | 343 | ## Custom QuerySets and Managers 344 | 345 | Custom `QuerySet`s that will be used in CTE queries should be derived from 346 | `CTEQuerySet`. 347 | 348 | ```py 349 | class LargeOrdersQuerySet(CTEQuerySet): 350 | def big_amounts(self): 351 | return self.filter(amount__gt=100) 352 | 353 | 354 | class Order(Model): 355 | amount = models.IntegerField() 356 | large = LargeOrdersQuerySet.as_manager() 357 | ``` 358 | 359 | Custom `CTEQuerySet`s can also be used with custom `CTEManager`s. 360 | 361 | ```py 362 | class CustomManager(CTEManager): 363 | ... 364 | 365 | 366 | class Order(Model): 367 | large = CustomManager.from_queryset(LargeOrdersQuerySet)() 368 | objects = CustomManager() 369 | ``` 370 | 371 | 372 | ## Experimental: Left Outer Join 373 | 374 | Django does not provide precise control over joins, but there is an experimental 375 | way to perform a `LEFT OUTER JOIN` with a CTE query using the `_join_type` 376 | keyword argument of `With.join(...)`. 377 | 378 | ```py 379 | from django.db.models.sql.constants import LOUTER 380 | 381 | totals = With( 382 | Order.objects 383 | .values("region_id") 384 | .annotate(total=Sum("amount")) 385 | .filter(total__gt=100) 386 | ) 387 | orders = ( 388 | totals 389 | .join(Order, region=totals.col.region_id, _join_type=LOUTER) 390 | .with_cte(totals) 391 | .annotate(region_total=totals.col.total) 392 | ) 393 | ``` 394 | 395 | Which produces the following SQL 396 | 397 | ```sql 398 | WITH RECURSIVE "cte" AS ( 399 | SELECT 400 | "orders"."region_id", 401 | SUM("orders"."amount") AS "total" 402 | FROM "orders" 403 | GROUP BY "orders"."region_id" 404 | HAVING SUM("orders"."amount") > 100 405 | ) 406 | SELECT 407 | "orders"."id", 408 | "orders"."region_id", 409 | "orders"."amount", 410 | "cte"."total" AS "region_total" 411 | FROM "orders" 412 | LEFT OUTER JOIN "cte" ON "orders"."region_id" = "cte"."region_id" 413 | ``` 414 | 415 | WARNING: as noted, this feature is experimental. There may be scenarios where 416 | Django automatically converts a `LEFT OUTER JOIN` to an `INNER JOIN` in the 417 | process of building the query. Be sure to test your queries to ensure they 418 | produce the desired SQL. 419 | 420 | 421 | ## Materialized CTE 422 | 423 | Both PostgreSQL 12+ and sqlite 3.35+ supports `MATERIALIZED` keyword for CTE queries. 424 | To enforce using of this keyword add `materialized` as a parameter of `With(..., materialized=True)`. 425 | 426 | 427 | ```py 428 | cte = With( 429 | Order.objects.values('id'), 430 | materialized=True 431 | ) 432 | ``` 433 | 434 | Which produces this SQL: 435 | 436 | ```sql 437 | WITH RECURSIVE "cte" AS MATERIALIZED ( 438 | SELECT 439 | "orders"."id" 440 | FROM "orders" 441 | ) 442 | ... 443 | ``` 444 | 445 | 446 | ## Raw CTE SQL 447 | 448 | Some queries are easier to construct with raw SQL than with the Django ORM. 449 | `raw_cte_sql()` is one solution for situations like that. The down-side is that 450 | each result field in the raw query must be explicitly mapped to a field type. 451 | The up-side is that there is no need to compromise result-set expressiveness 452 | with the likes of `Manager.raw()`. 453 | 454 | A short example: 455 | 456 | ```py 457 | from django.db.models import IntegerField, TextField 458 | from django_cte.raw import raw_cte_sql 459 | 460 | cte = With(raw_cte_sql( 461 | """ 462 | SELECT region_id, AVG(amount) AS avg_order 463 | FROM orders 464 | WHERE region_id = %s 465 | GROUP BY region_id 466 | """, 467 | ["moon"], 468 | { 469 | "region_id": TextField(), 470 | "avg_order": IntegerField(), 471 | }, 472 | )) 473 | moon_avg = ( 474 | cte 475 | .join(Region, name=cte.col.region_id) 476 | .annotate(avg_order=cte.col.avg_order) 477 | .with_cte(cte) 478 | ) 479 | ``` 480 | 481 | Which produces this SQL: 482 | 483 | ```sql 484 | WITH RECURSIVE "cte" AS ( 485 | SELECT region_id, AVG(amount) AS avg_order 486 | FROM orders 487 | WHERE region_id = 'moon' 488 | GROUP BY region_id 489 | ) 490 | SELECT 491 | "region"."name", 492 | "region"."parent_id", 493 | "cte"."avg_order" AS "avg_order" 494 | FROM "region" 495 | INNER JOIN "cte" ON "region"."name" = "cte"."region_id" 496 | ``` 497 | 498 | **WARNING**: Be very careful when writing raw SQL. Use bind parameters to 499 | prevent SQL injection attacks. 500 | 501 | 502 | ## More Advanced Use Cases 503 | 504 | A few more advanced techniques as well as example query results can be found 505 | in the tests: 506 | 507 | - [`test_cte.py`](https://github.com/dimagi/django-cte/blob/main/tests/test_cte.py) 508 | - [`test_recursive.py`](https://github.com/dimagi/django-cte/blob/main/tests/test_recursive.py) 509 | - [`test_raw.py`](https://github.com/dimagi/django-cte/blob/main/tests/test_raw.py) 510 | -------------------------------------------------------------------------------- /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 | 3 | import django 4 | from unmagic import fixture 5 | 6 | # django setup must occur before importing models 7 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") 8 | django.setup() 9 | 10 | from .django_setup import init_db, destroy_db # noqa 11 | 12 | 13 | @fixture(autouse=__name__, scope="package") 14 | def test_db(): 15 | init_db() 16 | yield 17 | destroy_db() 18 | -------------------------------------------------------------------------------- /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 | AutoField, 6 | CharField, 7 | ForeignKey, 8 | IntegerField, 9 | TextField, 10 | ) 11 | 12 | from django_cte import CTEManager, CTEQuerySet 13 | 14 | 15 | class LT40QuerySet(CTEQuerySet): 16 | 17 | def lt40(self): 18 | return self.filter(amount__lt=40) 19 | 20 | 21 | class LT30QuerySet(CTEQuerySet): 22 | 23 | def lt30(self): 24 | return self.filter(amount__lt=30) 25 | 26 | 27 | class LT25QuerySet(CTEQuerySet): 28 | 29 | def lt25(self): 30 | return self.filter(amount__lt=25) 31 | 32 | 33 | class LTManager(CTEManager): 34 | pass 35 | 36 | 37 | class Region(Model): 38 | objects = CTEManager() 39 | name = TextField(primary_key=True) 40 | parent = ForeignKey("self", null=True, on_delete=CASCADE) 41 | 42 | class Meta: 43 | db_table = "region" 44 | 45 | 46 | class User(Model): 47 | id = AutoField(primary_key=True) 48 | name = TextField() 49 | 50 | class Meta: 51 | db_table = "user" 52 | 53 | 54 | class Order(Model): 55 | objects = CTEManager() 56 | id = AutoField(primary_key=True) 57 | region = ForeignKey(Region, on_delete=CASCADE) 58 | amount = IntegerField(default=0) 59 | user = ForeignKey(User, null=True, on_delete=CASCADE) 60 | 61 | class Meta: 62 | db_table = "orders" 63 | 64 | 65 | class OrderFromLT40(Order): 66 | class Meta: 67 | proxy = True 68 | objects = CTEManager.from_queryset(LT40QuerySet)() 69 | 70 | 71 | class OrderLT40AsManager(Order): 72 | class Meta: 73 | proxy = True 74 | objects = LT40QuerySet.as_manager() 75 | 76 | 77 | class OrderCustomManagerNQuery(Order): 78 | class Meta: 79 | proxy = True 80 | objects = LTManager.from_queryset(LT25QuerySet)() 81 | 82 | 83 | class OrderCustomManager(Order): 84 | class Meta: 85 | proxy = True 86 | objects = LTManager() 87 | 88 | 89 | class OrderPlainManager(Order): 90 | class Meta: 91 | proxy = True 92 | objects = Manager() 93 | 94 | 95 | class KeyPair(Model): 96 | objects = CTEManager() 97 | key = CharField(max_length=32) 98 | value = IntegerField(default=0) 99 | parent = ForeignKey("self", null=True, on_delete=CASCADE) 100 | 101 | class Meta: 102 | db_table = "keypair" 103 | -------------------------------------------------------------------------------- /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 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_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 | 644 | def test_django52_resolve_ref_regression(self): 645 | cte = With( 646 | Order.objects.annotate( 647 | pnt_id=F("region__parent_id"), 648 | region_name=F("region__name"), 649 | ).values( 650 | # important: more than one query.select field 651 | "region_id", 652 | "amount", 653 | # important: more than one query.annotations field 654 | "pnt_id", 655 | "region_name", 656 | ) 657 | ) 658 | qs = ( 659 | cte.queryset() 660 | .with_cte(cte) 661 | .values( 662 | amt=cte.col.amount, 663 | pnt_id=cte.col.pnt_id, 664 | region_name=cte.col.region_name, 665 | ) 666 | .filter(region_id="earth") 667 | .order_by("amount") 668 | ) 669 | print(qs.query) 670 | self.assertEqual(list(qs), [ 671 | {'amt': 30, 'region_name': 'earth', 'pnt_id': 'sun'}, 672 | {'amt': 31, 'region_name': 'earth', 'pnt_id': 'sun'}, 673 | {'amt': 32, 'region_name': 'earth', 'pnt_id': 'sun'}, 674 | {'amt': 33, 'region_name': 'earth', 'pnt_id': 'sun'}, 675 | ]) 676 | -------------------------------------------------------------------------------- /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, 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_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_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_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 | --------------------------------------------------------------------------------