pax_global_header00006660000000000000000000000064150240336470014516gustar00rootroot0000000000000052 comment=f97b4ade258a29b036de82ae3a9067258cb65e4a django-cte-2.0.0/000077500000000000000000000000001502403364700135305ustar00rootroot00000000000000django-cte-2.0.0/.github/000077500000000000000000000000001502403364700150705ustar00rootroot00000000000000django-cte-2.0.0/.github/workflows/000077500000000000000000000000001502403364700171255ustar00rootroot00000000000000django-cte-2.0.0/.github/workflows/pypi.yml000066400000000000000000000041331502403364700206320ustar00rootroot00000000000000name: Publish Python distribution to PyPI and TestPyPI # Source: # https://packaging.python.org/en/latest/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/ on: push: branches: - main tags: - 'v*' workflow_dispatch: jobs: build: name: Build distribution package runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: astral-sh/setup-uv@v6 with: version: '>=0.7' - name: Check for version match in git tag and django_cte.__version__ if: startsWith(github.ref, 'refs/tags/v') run: uvx pyverno check django_cte/__init__.py "${{ github.ref }}" - name: Add untagged version suffix if: ${{ ! startsWith(github.ref, 'refs/tags/v') }} run: uvx pyverno update django_cte/__init__.py - name: Build a binary wheel and a source tarball run: uv build - name: Store the distribution packages uses: actions/upload-artifact@v4 with: name: python-package-distributions path: dist/ pypi-publish: name: Upload release to PyPI needs: [build] runs-on: ubuntu-latest environment: name: pypi url: https://pypi.org/p/django-cte permissions: id-token: write steps: - name: Download all the dists uses: actions/download-artifact@v4 with: name: python-package-distributions path: dist/ - name: Publish package distributions to PyPI uses: pypa/gh-action-pypi-publish@release/v1 pypi-test-publish: name: Upload release to test PyPI needs: [build] runs-on: ubuntu-latest environment: name: testpypi url: https://test.pypi.org/p/django-cte permissions: id-token: write steps: - name: Download all the dists uses: actions/download-artifact@v4 with: name: python-package-distributions path: dist/ - name: Publish package distributions to PyPI uses: pypa/gh-action-pypi-publish@release/v1 with: repository-url: https://test.pypi.org/legacy/ django-cte-2.0.0/.github/workflows/tests.yml000066400000000000000000000054651502403364700210240ustar00rootroot00000000000000name: django-cte tests on: push: branches: [main] pull_request: branches: [main] workflow_dispatch: jobs: configure: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Read Python versions from pyproject.toml id: read-python-versions # produces output like: python_versions=[ "3.9", "3.10", "3.11", "3.12" ] run: >- echo "python_versions=$( grep -oP '(?<=Language :: Python :: )\d\.\d+' pyproject.toml | jq --raw-input . | jq --slurp . | tr '\n' ' ' )" >> $GITHUB_OUTPUT - name: Read Django versions from pyproject.toml id: read-django-versions # django_versions=[ "Django~=4.2.0", "Django~=5.1.0", "Django~=5.2.0" ] run: >- echo "django_versions=$( grep -oP '(?<=Framework :: Django :: )\d+\.\d+' pyproject.toml | sed -E 's/(.+)/Django~=\1.0/' | jq --raw-input . | jq --slurp . | tr '\n' ' ' )" >> $GITHUB_OUTPUT outputs: python_versions: ${{ steps.read-python-versions.outputs.python_versions }} django_versions: ${{ steps.read-django-versions.outputs.django_versions }} tests: needs: [configure] runs-on: ubuntu-latest strategy: fail-fast: false matrix: python: ${{ fromJSON(needs.configure.outputs.python_versions) }} django: ${{ fromJSON(needs.configure.outputs.django_versions) }} exclude: - {python: '3.9', django: 'Django~=5.1.0'} - {python: '3.9', django: 'Django~=5.2.0'} env: allowed_python_failure: '3.14' services: postgres: image: postgres:latest env: POSTGRES_DB: postgres POSTGRES_PASSWORD: postgres POSTGRES_USER: postgres ports: - 5432:5432 options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 steps: - uses: actions/checkout@v3 - uses: astral-sh/setup-uv@v6 with: version: '>=0.7' python-version: ${{ matrix.python }} - name: Setup run: | uv sync --locked --no-install-package=django uv pip install "${{ matrix.django }}" - name: Run tests on PostgreSQL env: DB_SETTINGS: >- { "ENGINE":"django.db.backends.postgresql_psycopg2", "NAME":"django_cte", "USER":"postgres", "PASSWORD":"postgres", "HOST":"localhost", "PORT":"5432" } run: .venv/bin/pytest -v continue-on-error: ${{ matrix.python == env.allowed_python_failure }} - name: Run tests on SQLite run: .venv/bin/pytest -v continue-on-error: ${{ matrix.python == env.allowed_python_failure }} - name: Check style run: .venv/bin/ruff check django-cte-2.0.0/.gitignore000066400000000000000000000022051502403364700155170ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python env/ build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ *.egg-info/ .installed.cfg *.egg # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover .hypothesis/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # pyenv .python-version # celery beat schedule file celerybeat-schedule # SageMath parsed files *.sage.py # dotenv .env # virtualenv .venv venv/ ENV/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ django-cte-2.0.0/CHANGELOG.md000066400000000000000000000077641502403364700153570ustar00rootroot00000000000000# Django CTE change log ## 2.0.0 - 2025-06-16 - **API overhaul** - `With` has been renamed to `CTE`. `With` is deprecated and will be removed in a future version of django-cte. - `with_cte` was moved from a `CTEQuerySet` method to a stand-alone function. - `CTEManager` and `CTEQuerySet` are deprecated and should be removed from code that uses them, as they are no longer necessary. They will be removed in a future version of django-cte. - Reference the [documentation](https://dimagi.github.io/django-cte/) for new usage patterns. - **BREAKING:** On Django 5.2 and later, the name specified in `.values('fk_name')` must match the name of the same column referenced by `cte.col.fk_name`—for example, in a join condition. It may end with `_id` or not, but the references must be consistent. This change may require previously working CTE queries to be adjusted when migrating to Django 5.2 ([example](https://github.com/dimagi/django-cte/commit/321d92cd8d1edd515c1f5000a3b12c35265aa4f8)). - Django 5.0 is EOL and no longer supported. - Fixed broken `UNION` and other "combined" queries. - Internally, the library has been updated to simplify the code and remove workarounds for old and unsupported versions of Django. - Modernized development tooling - Replaced _nosetests_ with _pytest_. - Replaced _setup.py_ with _pyproject.toml_ - Replaced _flake8_ with _ruff_. - Replaced _venv/pip_ with _uv_. - Improved Github Actions automation, including automated releases. - Dev versions of django-cte are now published on PyPI, making them easier to test and use before an official release is cut. ## 1.3.3 - 2024-06-07 - Handle empty result sets in CTEs ([#92](https://github.com/dimagi/django-cte/pull/92)). - Fix `.explain()` in Django >= 4.0 ([#91](https://github.com/dimagi/django-cte/pull/91)). - Fixed bug in deferred loading ([#90](https://github.com/dimagi/django-cte/pull/90)). ## 1.3.2 - 2023-11-20 - Work around changes in Django 4.2 that broke CTE queries due to internally generated column aliases in the query compiler. The workaround is not always effective. Some queries will produce mal-formed SQL. For example, CTE queries with window functions. ## 1.3.1 - 2023-06-13 - Fix: `.update()` did not work when using CTE manager or when accessing nested tables. ## 1.3.0 - 2023-05-24 - Add support for Materialized CTEs. - Fix: add EXPLAIN clause in correct position when using `.explain()` method. ## v1.2.1 - 2022-07-07 - Fix compatibility with non-CTE models. ## v1.2.0 - 2022-03-30 - Add support for Django 3.1, 3.2 and 4.0. - Quote the CTE table name if needed. - Resolve `OuterRef` in CTE `Subquery`. - Fix default `CTEManager` so it can use `from_queryset` corectly. - Fix for Django 3.0.5+. ## v1.1.5 - 2020-02-07 - Django 3 compatibility. Thank you @tim-schilling and @ryanhiebert! ## v1.1.4 - 2018-07-30 - Python 3 compatibility. ## v1.1.3 - 2018-06-19 - Fix CTE alias bug. ## v1.1.2 - 2018-05-22 - Use `_default_manager` instead of `objects`. ## v1.1.1 - 2018-04-13 - Fix recursive CTE pickling. Note: this is currently [broken on Django master](https://github.com/django/django/pull/9134#pullrequestreview-112057277). ## v1.1.0 - 2018-04-09 - `With.queryset()` now uses the CTE model's manager to create a new `QuerySet`, which makes it easier to work with custom `QuerySet` classes. ## v1.0.0 - 2018-04-04 - BACKWARD INCOMPATIBLE CHANGE: `With.queryset()` no longer accepts a `model` argument. - Improve `With.queryset()` to select directly from the CTE rather than joining to anoter QuerySet. - Refactor `With.join()` to use real JOIN clause. ## v0.1.4 - 2018-03-21 - Fix related field attname masking CTE column. ## v0.1.3 - 2018-03-15 - Add `django_cte.raw.raw_cte_sql` for constructing CTEs with raw SQL. ## v0.1.2 - 2018-02-21 - Improve error on bad recursive reference. - Add more tests. - Add change log. - Improve README. - PEP-8 style fixes. ## v0.1.1 - 2018-02-21 - Fix readme formatting on PyPI. ## v0.1 - 2018-02-21 - Initial implementation. django-cte-2.0.0/LICENSE000066400000000000000000000027441502403364700145440ustar00rootroot00000000000000Copyright (c) 2018, Dimagi Inc., and individual contributors. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name Dimagi, nor the names of its contributors, may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL DIMAGI INC. BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. django-cte-2.0.0/README.md000066400000000000000000000025601502403364700150120ustar00rootroot00000000000000# Common Table Expressions with Django [![Build Status](https://github.com/dimagi/django-cte/actions/workflows/tests.yml/badge.svg)](https://github.com/dimagi/django-cte/actions/workflows/tests.yml) [![PyPI version](https://badge.fury.io/py/django-cte.svg)](https://badge.fury.io/py/django-cte) ## Installation ``` pip install django-cte ``` ## Documentation The [django-cte documentation](https://dimagi.github.io/django-cte/) shows how to use Common Table Expressions with the Django ORM. ## Running tests ``` cd django-cte uv sync pytest ruff check # To run tests against postgres psql -U username -h localhost -p 5432 -c 'create database django_cte;' export PG_DB_SETTINGS='{ "ENGINE":"django.db.backends.postgresql_psycopg2", "NAME":"django_cte", "USER":"username", "PASSWORD":"password", "HOST":"localhost", "PORT":"5432"}' # WARNING pytest will delete the test_django_cte database if it exists! DB_SETTINGS="$PG_DB_SETTINGS" pytest ``` All feature and bug contributions are expected to be covered by tests. ## Publishing a new verison to PyPI Push a new tag to Github using the format vX.Y.Z where X.Y.Z matches the version in [`__init__.py`](django_cte/__init__.py). A new version is published to https://test.pypi.org/p/django-cte on every push to the *main* branch. Publishing is automated with [Github Actions](.github/workflows/pypi.yml). django-cte-2.0.0/django_cte/000077500000000000000000000000001502403364700156255ustar00rootroot00000000000000django-cte-2.0.0/django_cte/__init__.py000066400000000000000000000001731502403364700177370ustar00rootroot00000000000000from .cte import CTE, with_cte, CTEManager, CTEQuerySet, With # noqa __version__ = "2.0.0" __all__ = ["CTE", "with_cte"] django-cte-2.0.0/django_cte/_deprecated.py000066400000000000000000000127671502403364700204530ustar00rootroot00000000000000try: from warnings import deprecated except ImportError: from warnings import warn # Copied from Python 3.13, lightly modified for Python 3.9 compatibility. # Can be removed when the oldest supported Python version is 3.13. class deprecated: """Indicate that a class, function or overload is deprecated. When this decorator is applied to an object, the type checker will generate a diagnostic on usage of the deprecated object. Usage: @deprecated("Use B instead") class A: pass @deprecated("Use g instead") def f(): pass @overload @deprecated("int support is deprecated") def g(x: int) -> int: ... @overload def g(x: str) -> int: ... The warning specified by *category* will be emitted at runtime on use of deprecated objects. For functions, that happens on calls; for classes, on instantiation and on creation of subclasses. If the *category* is ``None``, no warning is emitted at runtime. The *stacklevel* determines where the warning is emitted. If it is ``1`` (the default), the warning is emitted at the direct caller of the deprecated object; if it is higher, it is emitted further up the stack. Static type checker behavior is not affected by the *category* and *stacklevel* arguments. The deprecation message passed to the decorator is saved in the ``__deprecated__`` attribute on the decorated object. If applied to an overload, the decorator must be after the ``@overload`` decorator for the attribute to exist on the overload as returned by ``get_overloads()``. See PEP 702 for details. """ def __init__( self, message: str, /, *, category=DeprecationWarning, stacklevel=1, ): if not isinstance(message, str): raise TypeError( f"Expected an object of type str for 'message', not {type(message).__name__!r}" ) self.message = message self.category = category self.stacklevel = stacklevel def __call__(self, arg, /): # Make sure the inner functions created below don't # retain a reference to self. msg = self.message category = self.category stacklevel = self.stacklevel if category is None: arg.__deprecated__ = msg return arg elif isinstance(arg, type): import functools from types import MethodType original_new = arg.__new__ @functools.wraps(original_new) def __new__(cls, /, *args, **kwargs): if cls is arg: warn(msg, category=category, stacklevel=stacklevel + 1) if original_new is not object.__new__: return original_new(cls, *args, **kwargs) # Mirrors a similar check in object.__new__. elif cls.__init__ is object.__init__ and (args or kwargs): raise TypeError(f"{cls.__name__}() takes no arguments") else: return original_new(cls) arg.__new__ = staticmethod(__new__) original_init_subclass = arg.__init_subclass__ # We need slightly different behavior if __init_subclass__ # is a bound method (likely if it was implemented in Python) if isinstance(original_init_subclass, MethodType): original_init_subclass = original_init_subclass.__func__ @functools.wraps(original_init_subclass) def __init_subclass__(*args, **kwargs): warn(msg, category=category, stacklevel=stacklevel + 1) return original_init_subclass(*args, **kwargs) arg.__init_subclass__ = classmethod(__init_subclass__) # Or otherwise, which likely means it's a builtin such as # object's implementation of __init_subclass__. else: @functools.wraps(original_init_subclass) def __init_subclass__(*args, **kwargs): warn(msg, category=category, stacklevel=stacklevel + 1) return original_init_subclass(*args, **kwargs) arg.__init_subclass__ = __init_subclass__ arg.__deprecated__ = __new__.__deprecated__ = msg __init_subclass__.__deprecated__ = msg return arg elif callable(arg): import functools import inspect @functools.wraps(arg) def wrapper(*args, **kwargs): warn(msg, category=category, stacklevel=stacklevel + 1) return arg(*args, **kwargs) if inspect.iscoroutinefunction(arg): wrapper = inspect.markcoroutinefunction(wrapper) arg.__deprecated__ = wrapper.__deprecated__ = msg return wrapper else: raise TypeError( "@deprecated decorator with non-None category must be applied to " f"a class or callable, not {arg!r}" ) django-cte-2.0.0/django_cte/cte.py000066400000000000000000000177011502403364700167600ustar00rootroot00000000000000from copy import copy from django.db.models import Manager, sql from django.db.models.expressions import Ref from django.db.models.query import Q, QuerySet, ValuesIterable from django.db.models.sql.datastructures import BaseTable from .jitmixin import jit_mixin from .join import QJoin, INNER from .meta import CTEColumnRef, CTEColumns from .query import CTEQuery from ._deprecated import deprecated __all__ = ["CTE", "with_cte"] def with_cte(*ctes, select): """Add Common Table Expression(s) (CTEs) to a model or queryset :param *ctes: One or more CTE objects. :param select: A model class, queryset, or CTE to use as the base query to which CTEs are attached. :returns: A queryset with the given CTE added to it. """ if isinstance(select, CTE): select = select.queryset() elif not isinstance(select, QuerySet): select = select._default_manager.all() jit_mixin(select.query, CTEQuery) select.query._with_ctes += ctes return select class CTE: """Common Table Expression :param queryset: A queryset to use as the body of the CTE. :param name: Optional name parameter for the CTE (default: "cte"). This must be a unique name that does not conflict with other entities (tables, views, functions, other CTE(s), etc.) referenced in the given query as well any query to which this CTE will eventually be added. :param materialized: Optional parameter (default: False) which enforce using of MATERIALIZED statement for supporting databases. """ def __init__(self, queryset, name="cte", materialized=False): self.query = None if queryset is None else queryset.query self.name = name self.col = CTEColumns(self) self.materialized = materialized def __getstate__(self): return (self.query, self.name, self.materialized) def __setstate__(self, state): self.query, self.name, self.materialized = state self.col = CTEColumns(self) def __repr__(self): return "".format(self.name) @classmethod def recursive(cls, make_cte_queryset, name="cte", materialized=False): """Recursive Common Table Expression :param make_cte_queryset: Function taking a single argument (a not-yet-fully-constructed cte object) and returning a `QuerySet` object. The returned `QuerySet` normally consists of an initial statement unioned with a recursive statement. :param name: See `name` parameter of `__init__`. :param materialized: See `materialized` parameter of `__init__`. :returns: The fully constructed recursive cte object. """ cte = cls(None, name, materialized) cte.query = make_cte_queryset(cte).query return cte def join(self, model_or_queryset, *filter_q, **filter_kw): """Join this CTE to the given model or queryset This CTE will be referenced by the returned queryset, but the corresponding `WITH ...` statement will not be prepended to the queryset's SQL output; use `with_cte(cte, select=cte.join(...))` to achieve that outcome. :param model_or_queryset: Model class or queryset to which the CTE should be joined. :param *filter_q: Join condition Q expressions (optional). :param **filter_kw: Join conditions. All LHS fields (kwarg keys) are assumed to reference `model_or_queryset` fields. Use `cte.col.name` on the RHS to recursively reference CTE query columns. For example: `cte.join(Book, id=cte.col.id)` :returns: A queryset with the given model or queryset joined to this CTE. """ if isinstance(model_or_queryset, QuerySet): queryset = model_or_queryset.all() else: queryset = model_or_queryset._default_manager.all() join_type = filter_kw.pop("_join_type", INNER) query = queryset.query # based on Query.add_q: add necessary joins to query, but no filter q_object = Q(*filter_q, **filter_kw) map = query.alias_map existing_inner = set(a for a in map if map[a].join_type == INNER) on_clause, _ = query._add_q(q_object, query.used_aliases) query.demote_joins(existing_inner) parent = query.get_initial_alias() query.join(QJoin(parent, self.name, self.name, on_clause, join_type)) return queryset def queryset(self): """Get a queryset selecting from this CTE This CTE will be referenced by the returned queryset, but the corresponding `WITH ...` statement will not be prepended to the queryset's SQL output; use `with_cte(cte, select=cte)` to do that. :returns: A queryset. """ cte_query = self.query qs = cte_query.model._default_manager.get_queryset() query = jit_mixin(sql.Query(cte_query.model), CTEQuery) query.join(BaseTable(self.name, None)) query.default_cols = cte_query.default_cols query.deferred_loading = cte_query.deferred_loading if cte_query.values_select: query.set_values(cte_query.values_select) qs._iterable_class = ValuesIterable for alias in getattr(cte_query, "selected", None) or (): if alias not in cte_query.annotations: col = Ref(alias, cte_query.resolve_ref(alias)) query.add_annotation(col, alias) if cte_query.annotations: for alias, value in cte_query.annotations.items(): col = CTEColumnRef(alias, self.name, value.output_field) query.add_annotation(col, alias) query.annotation_select_mask = cte_query.annotation_select_mask qs.query = query return qs def _resolve_ref(self, name): selected = getattr(self.query, "selected", None) if selected and name in selected and name not in self.query.annotations: return Ref(name, self.query.resolve_ref(name)) return self.query.resolve_ref(name) def resolve_expression(self, *args, **kw): if self.query is None: raise ValueError("Cannot resolve recursive CTE without a query.") clone = copy(self) clone.query = clone.query.resolve_expression(*args, **kw) return clone @deprecated("Use `django_cte.CTE` instead.") class With(CTE): @staticmethod @deprecated("Use `django_cte.CTE.recursive` instead.") def recursive(*args, **kw): return CTE.recursive(*args, **kw) @deprecated("CTEQuerySet is deprecated. " "CTEs can now be applied to any queryset using `with_cte()`") class CTEQuerySet(QuerySet): """QuerySet with support for Common Table Expressions""" def __init__(self, model=None, query=None, using=None, hints=None): # Only create an instance of a Query if this is the first invocation in # a query chain. super(CTEQuerySet, self).__init__(model, query, using, hints) jit_mixin(self.query, CTEQuery) @deprecated("Use `django_cte.with_cte(cte, select=...)` instead.") def with_cte(self, cte): qs = self._clone() qs.query._with_ctes += cte, return qs def as_manager(cls): # Address the circular dependency between # `CTEQuerySet` and `CTEManager`. manager = CTEManager.from_queryset(cls)() manager._built_with_as_manager = True return manager as_manager.queryset_only = True as_manager = classmethod(as_manager) @deprecated("CTEMAnager is deprecated. " "CTEs can now be applied to any queryset using `with_cte()`") class CTEManager(Manager.from_queryset(CTEQuerySet)): """Manager for models that perform CTE queries""" @classmethod def from_queryset(cls, queryset_class, class_name=None): if not issubclass(queryset_class, CTEQuerySet): raise TypeError( "models with CTE support need to use a CTEQuerySet") return super(CTEManager, cls).from_queryset( queryset_class, class_name=class_name) django-cte-2.0.0/django_cte/jitmixin.py000066400000000000000000000015651502403364700200410ustar00rootroot00000000000000def jit_mixin(obj, mixin): """Apply mixin to object and return the object""" if not isinstance(obj, mixin): obj.__class__ = jit_mixin_type(obj.__class__, mixin) return obj def jit_mixin_type(base, *mixins): assert not issubclass(base, mixins), (base, mixins) mixed = _mixin_cache.get((base, mixins)) if mixed is None: prefix = "".join(m._jit_mixin_prefix for m in mixins) name = f"{prefix}{base.__name__}" mixed = _mixin_cache[(base, mixins)] = type(name, (*mixins, base), { "_jit_mixin_base": getattr(base, "_jit_mixin_base", base), "_jit_mixins": mixins + getattr(base, "_jit_mixins", ()), }) return mixed _mixin_cache = {} class JITMixin: def __reduce__(self): # make JITMixin classes pickleable return (jit_mixin_type, (self._jit_mixin_base, *self._jit_mixins)) django-cte-2.0.0/django_cte/join.py000066400000000000000000000061241502403364700171410ustar00rootroot00000000000000from django.db.models.sql.constants import INNER class QJoin: """Join clause with join condition from Q object clause :param parent_alias: Alias of parent table. :param table_name: Name of joined table. :param table_alias: Alias of joined table. :param on_clause: Query `where_class` instance represenging the ON clause. :param join_type: Join type (INNER or LOUTER). """ filtered_relation = None def __init__(self, parent_alias, table_name, table_alias, on_clause, join_type=INNER, nullable=None): self.parent_alias = parent_alias self.table_name = table_name self.table_alias = table_alias self.on_clause = on_clause self.join_type = join_type # LOUTER or INNER self.nullable = join_type != INNER if nullable is None else nullable @property def identity(self): return ( self.__class__, self.table_name, self.parent_alias, self.join_type, self.on_clause, ) def __hash__(self): return hash(self.identity) def __eq__(self, other): if not isinstance(other, QJoin): return NotImplemented return self.identity == other.identity def equals(self, other): return self.identity == other.identity def as_sql(self, compiler, connection): """Generate join clause SQL""" on_clause_sql, params = self.on_clause.as_sql(compiler, connection) if self.table_alias == self.table_name: alias = '' else: alias = ' %s' % self.table_alias qn = compiler.quote_name_unless_alias sql = '%s %s%s ON %s' % ( self.join_type, qn(self.table_name), alias, on_clause_sql ) return sql, params def relabeled_clone(self, change_map): return self.__class__( parent_alias=change_map.get(self.parent_alias, self.parent_alias), table_name=self.table_name, table_alias=change_map.get(self.table_alias, self.table_alias), on_clause=self.on_clause.relabeled_clone(change_map), join_type=self.join_type, nullable=self.nullable, ) class join_field: # `Join.join_field` is used internally by `Join` as well as in # `QuerySet.resolve_expression()`: # # isinstance(table, Join) # and table.join_field.related_model._meta.db_table != alias # # Currently that does not apply here since `QJoin` is not an # instance of `Join`, although maybe it should? Maybe this # should have `related_model._meta.db_table` return # `.table_name` or `.table_alias`? # # `PathInfo.join_field` is another similarly named attribute in # Django that has a much more complicated interface, but luckily # seems unrelated to `Join.join_field`. class related_model: class _meta: # for QuerySet.set_group_by(allow_aliases=True) local_concrete_fields = () django-cte-2.0.0/django_cte/meta.py000066400000000000000000000066431502403364700171360ustar00rootroot00000000000000import weakref from django.db.models.expressions import Col, Expression class CTEColumns: def __init__(self, cte): self._cte = weakref.ref(cte) def __getattr__(self, name): return CTEColumn(self._cte(), name) class CTEColumn(Expression): def __init__(self, cte, name, output_field=None): self._cte = cte self.table_alias = cte.name self.name = self.alias = name self._output_field = output_field def __repr__(self): return "<{} {}.{}>".format( self.__class__.__name__, self._cte.name, self.name, ) @property def _ref(self): if self._cte.query is None: raise ValueError( "cannot resolve '{cte}.{name}' in recursive CTE setup. " "Hint: use ExpressionWrapper({cte}.col.{name}, " "output_field=...)".format(cte=self._cte.name, name=self.name) ) ref = self._cte._resolve_ref(self.name) if ref is self or self in ref.get_source_expressions(): raise ValueError("Circular reference: {} = {}".format(self, ref)) return ref @property def target(self): return self._ref.target @property def output_field(self): # required to fix error caused by django commit # 9d519d3dc4e5bd1d9ff3806b44624c3e487d61c1 if self._cte.query is None: raise AttributeError if self._output_field is not None: return self._output_field return self._ref.output_field def as_sql(self, compiler, connection): qn = compiler.quote_name_unless_alias ref = self._ref if isinstance(ref, Col) and self.name == "pk": column = ref.target.column else: column = self.name return "%s.%s" % (qn(self.table_alias), qn(column)), [] def relabeled_clone(self, relabels): if self.table_alias is not None and self.table_alias in relabels: clone = self.copy() clone.table_alias = relabels[self.table_alias] return clone return self class CTEColumnRef(Expression): def __init__(self, name, cte_name, output_field): self.name = name self.cte_name = cte_name self.output_field = output_field self._alias = None def resolve_expression(self, query=None, allow_joins=True, reuse=None, summarize=False, for_save=False): if query: clone = self.copy() clone._alias = self._alias or query.table_map.get( self.cte_name, [self.cte_name])[0] return clone return super().resolve_expression( query, allow_joins, reuse, summarize, for_save) def relabeled_clone(self, change_map): if ( self.cte_name not in change_map and self._alias not in change_map ): return super().relabeled_clone(change_map) clone = self.copy() if self.cte_name in change_map: clone._alias = change_map[self.cte_name] if self._alias in change_map: clone._alias = change_map[self._alias] return clone def as_sql(self, compiler, connection): qn = compiler.quote_name_unless_alias table = self._alias or compiler.query.table_map.get( self.cte_name, [self.cte_name])[0] return "%s.%s" % (qn(table), qn(self.name)), [] django-cte-2.0.0/django_cte/query.py000066400000000000000000000127161502403364700173530ustar00rootroot00000000000000import django from django.core.exceptions import EmptyResultSet from django.db.models.sql.constants import LOUTER from .jitmixin import JITMixin, jit_mixin from .join import QJoin # NOTE: it is currently not possible to execute delete queries that # reference CTEs without patching `QuerySet.delete` (Django method) # to call `self.query.chain(sql.DeleteQuery)` instead of # `sql.DeleteQuery(self.model)` class CTEQuery(JITMixin): """A Query mixin that processes SQL compilation through a CTE compiler""" _jit_mixin_prefix = "CTE" _with_ctes = () @property def combined_queries(self): return self.__dict__.get("combined_queries", ()) @combined_queries.setter def combined_queries(self, queries): ctes = [] seen = {cte.name: cte for cte in self._with_ctes} for query in queries: for cte in getattr(query, "_with_ctes", ()): if seen.get(cte.name) is cte: continue if cte.name in seen: raise ValueError( f"Found two or more CTEs named '{cte.name}'. " "Hint: assign a unique name to each CTE." ) ctes.append(cte) seen[cte.name] = cte if seen: def without_ctes(query): if getattr(query, "_with_ctes", None): query = query.clone() del query._with_ctes return query self._with_ctes += tuple(ctes) queries = tuple(without_ctes(q) for q in queries) self.__dict__["combined_queries"] = queries def resolve_expression(self, *args, **kwargs): clone = super().resolve_expression(*args, **kwargs) clone._with_ctes = tuple( cte.resolve_expression(*args, **kwargs) for cte in clone._with_ctes ) return clone def get_compiler(self, *args, **kwargs): return jit_mixin(super().get_compiler(*args, **kwargs), CTECompiler) def chain(self, klass=None): clone = jit_mixin(super().chain(klass), CTEQuery) clone._with_ctes = self._with_ctes return clone def generate_cte_sql(connection, query, as_sql): if not query._with_ctes: return as_sql() ctes = [] params = [] for cte in query._with_ctes: if django.VERSION > (4, 2): _ignore_with_col_aliases(cte.query) alias = query.alias_map.get(cte.name) should_elide_empty = ( not isinstance(alias, QJoin) or alias.join_type != LOUTER ) compiler = cte.query.get_compiler( connection=connection, elide_empty=should_elide_empty ) qn = compiler.quote_name_unless_alias try: cte_sql, cte_params = compiler.as_sql() except EmptyResultSet: # If the CTE raises an EmptyResultSet the SqlCompiler still # needs to know the information about this base compiler # like, col_count and klass_info. as_sql() raise template = get_cte_query_template(cte) ctes.append(template.format(name=qn(cte.name), query=cte_sql)) params.extend(cte_params) explain_attribute = "explain_info" explain_info = getattr(query, explain_attribute, None) explain_format = getattr(explain_info, "format", None) explain_options = getattr(explain_info, "options", {}) explain_query_or_info = getattr(query, explain_attribute, None) sql = [] if explain_query_or_info: sql.append( connection.ops.explain_query_prefix( explain_format, **explain_options ) ) # this needs to get set to None so that the base as_sql() doesn't # insert the EXPLAIN statement where it would end up between the # WITH ... clause and the final SELECT setattr(query, explain_attribute, None) if ctes: # Always use WITH RECURSIVE # https://www.postgresql.org/message-id/13122.1339829536%40sss.pgh.pa.us sql.extend(["WITH RECURSIVE", ", ".join(ctes)]) base_sql, base_params = as_sql() if explain_query_or_info: setattr(query, explain_attribute, explain_query_or_info) sql.append(base_sql) params.extend(base_params) return " ".join(sql), tuple(params) def get_cte_query_template(cte): if cte.materialized: return "{name} AS MATERIALIZED ({query})" return "{name} AS ({query})" def _ignore_with_col_aliases(cte_query): if getattr(cte_query, "combined_queries", None): cte_query.combined_queries = tuple( jit_mixin(q, NoAliasQuery) for q in cte_query.combined_queries ) class CTECompiler(JITMixin): """Mixin for django.db.models.sql.compiler.SQLCompiler""" _jit_mixin_prefix = "CTE" def as_sql(self, *args, **kwargs): def _as_sql(): return super(CTECompiler, self).as_sql(*args, **kwargs) return generate_cte_sql(self.connection, self.query, _as_sql) class NoAliasQuery(JITMixin): """Mixin for django.db.models.sql.compiler.Query""" _jit_mixin_prefix = "NoAlias" def get_compiler(self, *args, **kwargs): return jit_mixin(super().get_compiler(*args, **kwargs), NoAliasCompiler) class NoAliasCompiler(JITMixin): """Mixin for django.db.models.sql.compiler.SQLCompiler""" _jit_mixin_prefix = "NoAlias" def get_select(self, *, with_col_aliases=False, **kw): return super().get_select(**kw) django-cte-2.0.0/django_cte/raw.py000066400000000000000000000020251502403364700167670ustar00rootroot00000000000000def raw_cte_sql(sql, params, refs): """Raw CTE SQL :param sql: SQL query (string). :param params: List of bind parameters. :param refs: Dict of output fields: `{"name": }`. :returns: Object that can be passed to `With`. """ class raw_cte_ref: def __init__(self, output_field): self.output_field = output_field def get_source_expressions(self): return [] class raw_cte_compiler: def __init__(self, connection): self.connection = connection def as_sql(self): return sql, params def quote_name_unless_alias(self, name): return self.connection.ops.quote_name(name) class raw_cte_queryset: class query: @staticmethod def get_compiler(connection, *, elide_empty=None): return raw_cte_compiler(connection) @staticmethod def resolve_ref(name): return raw_cte_ref(refs[name]) return raw_cte_queryset django-cte-2.0.0/docs/000077500000000000000000000000001502403364700144605ustar00rootroot00000000000000django-cte-2.0.0/docs/_config.yml000066400000000000000000000001201502403364700166000ustar00rootroot00000000000000title: django-cte author: Dimagi markdown: kramdown kramdown: toc_levels: 2..3django-cte-2.0.0/docs/index.md000066400000000000000000000310321502403364700161100ustar00rootroot00000000000000# Common Table Expressions with Django * Table of contents (this line will not be displayed). {:toc} A Common Table Expression acts like a temporary table or view that exists only for the duration of the query it is attached to. django-cte allows common table expressions to be attached to normal Django ORM queries. ## Simple Common Table Expressions See [Appendix A](#appendix-a-model-definitions-used-in-sample-code) for model definitions used in sample code. Simple CTEs are constructed using `CTE(...)`. A CTE is added to a queryset using `with_cte(cte, select=queryset)`, which adds the `WITH` expression before the main `SELECT` query. A CTE can be joined to a model or other `QuerySet` using its `.join(...)` method, which creates a new queryset with a `JOIN` and `ON` condition. ```py from django_cte import CTE, with_cte cte = CTE( Order.objects .values("region_id") .annotate(total=Sum("amount")) ) orders = with_cte( # WITH cte ... cte, # SELECT ... FROM orders INNER JOIN cte ON orders.region_id = cte.region_id select=cte.join(Order, region=cte.col.region_id) # Annotate each Order with a "region_total" .annotate(region_total=cte.col.total) ) print(orders.query) # print SQL ``` The `orders` SQL, after formatting for readability, would look something like this: ```sql WITH RECURSIVE "cte" AS ( SELECT "orders"."region_id", SUM("orders"."amount") AS "total" FROM "orders" GROUP BY "orders"."region_id" ) SELECT "orders"."id", "orders"."region_id", "orders"."amount", "cte"."total" AS "region_total" FROM "orders" INNER JOIN "cte" ON "orders"."region_id" = "cte"."region_id" ``` The `orders` query is a queryset containing annotated `Order` objects, just as you would get from a query like `Order.objects.annotate(region_total=...)`. Each `Order` object will be annotated with a `region_total` attribute, which is populated with the value of the corresponding total from the joined CTE query. You may have noticed the CTE in this query uses `WITH RECURSIVE` even though this is not a [Recursive Common Table Expression](#recursive-common-table-expressions). The `RECURSIVE` keyword is always used, even for non-recursive CTEs. On databases such as PostgreSQL and SQLite this has no effect other than allowing recursive CTEs to be included in the WITH block. ## Recursive Common Table Expressions Recursive CTE queries allow fundamentally new types of queries that are not otherwise possible. Recursive CTEs are constructed using `CTE.recursive()`, which takes as its first argument a function that constructs and returns a recursive query. Recursive queries have two elements: first a non-recursive query element, and second a recursive query element. The second is typically attached to the first using `QuerySet.union()`. ```py def make_regions_cte(cte): # non-recursive: get root nodes return Region.objects.filter( parent__isnull=True ).values( "name", path=F("name"), depth=Value(0, output_field=IntegerField()), ).union( # recursive union: get descendants cte.join(Region, parent=cte.col.name).values( "name", path=Concat( cte.col.path, Value(" / "), F("name"), output_field=TextField(), ), depth=cte.col.depth + Value(1, output_field=IntegerField()), ), all=True, ) cte = CTE.recursive(make_regions_cte) regions = with_cte( cte, select=cte.join(Region, name=cte.col.name) .annotate( path=cte.col.path, depth=cte.col.depth, ) .filter(depth=2) .order_by("path") ) ``` `Region` objects returned by this query will have `path` and `depth` attributes. The results will be ordered by `path` (hierarchically by region name). The SQL produced by this query looks something like this: ```sql WITH RECURSIVE "cte" AS ( SELECT "region"."name", "region"."name" AS "path", 0 AS "depth" FROM "region" WHERE "region"."parent_id" IS NULL UNION ALL SELECT "region"."name", "cte"."path" || ' / ' || "region"."name" AS "path", "cte"."depth" + 1 AS "depth" FROM "region" INNER JOIN "cte" ON "region"."parent_id" = "cte"."name" ) SELECT "region"."name", "region"."parent_id", "cte"."path" AS "path", "cte"."depth" AS "depth" FROM "region" INNER JOIN "cte" ON "region"."name" = "cte"."name" WHERE "cte"."depth" = 2 ORDER BY "path" ASC ``` ## Named Common Table Expressions It is possible to add more than one CTE to a query. To do this, each CTE must have a unique name. `CTE(queryset)` returns a CTE with the name `'cte'` by default, but that can be overridden: `CTE(queryset, name='custom')` or `CTE.recursive(make_queryset, name='custom')`. This allows each CTE to be referenced uniquely within a single query. Also note that a CTE may reference other CTEs in the same query. Example query with two CTEs, and the second (`totals`) CTE references the first (`rootmap`): ```py def make_root_mapping(rootmap): return Region.objects.filter( parent__isnull=True ).values( "name", root=F("name"), ).union( rootmap.join(Region, parent=rootmap.col.name).values( "name", root=rootmap.col.root, ), all=True, ) rootmap = CTE.recursive(make_root_mapping, name="rootmap") totals = CTE( rootmap.join(Order, region_id=rootmap.col.name) .values( root=rootmap.col.root, ).annotate( orders_count=Count("id"), region_total=Sum("amount"), ), name="totals", ) root_regions = with_cte( # Important: add both CTEs to the query rootmap, totals, select=totals.join(Region, name=totals.col.root) .annotate( # count of orders in this region and all subregions orders_count=totals.col.orders_count, # sum of order amounts in this region and all subregions region_total=totals.col.region_total, ) ) ``` And the resulting SQL. ```sql WITH RECURSIVE "rootmap" AS ( SELECT "region"."name", "region"."name" AS "root" FROM "region" WHERE "region"."parent_id" IS NULL UNION ALL SELECT "region"."name", "rootmap"."root" AS "root" FROM "region" INNER JOIN "rootmap" ON "region"."parent_id" = "rootmap"."name" ), "totals" AS ( SELECT "rootmap"."root" AS "root", COUNT("orders"."id") AS "orders_count", SUM("orders"."amount") AS "region_total" FROM "orders" INNER JOIN "rootmap" ON "orders"."region_id" = "rootmap"."name" GROUP BY "rootmap"."root" ) SELECT "region"."name", "region"."parent_id", "totals"."orders_count" AS "orders_count", "totals"."region_total" AS "region_total" FROM "region" INNER JOIN "totals" ON "region"."name" = "totals"."root" ``` ## Selecting FROM a Common Table Expression Sometimes it is useful to construct queries where the final `FROM` clause contains only common table expression(s). This is possible with `CTE(...).queryset()`. Each returned row may be a model object: ```py cte = CTE( Order.objects .annotate(region_parent=F("region__parent_id")), ) orders = with_cte(cte, select=cte.queryset()) ``` And the resulting SQL: ```sql WITH RECURSIVE "cte" AS ( SELECT "orders"."id", "orders"."region_id", "orders"."amount", "region"."parent_id" AS "region_parent" FROM "orders" INNER JOIN "region" ON "orders"."region_id" = "region"."name" ) SELECT "cte"."id", "cte"."region_id", "cte"."amount", "cte"."region_parent" AS "region_parent" FROM "cte" ``` It is also possible to do the same with `values(...)` queries: ```py cte = CTE( Order.objects .values( "region_id", region_parent=F("region__parent_id"), ) .distinct() ) values = with_cte(cte, select=cte).filter(region_parent__isnull=False) ``` Which produces this SQL: ```sql WITH RECURSIVE "cte" AS ( SELECT DISTINCT "orders"."region_id", "region"."parent_id" AS "region_parent" FROM "orders" INNER JOIN "region" ON "orders"."region_id" = "region"."name" ) SELECT "cte"."region_id", "cte"."region_parent" AS "region_parent" FROM "cte" WHERE "cte"."region_parent" IS NOT NULL ``` You may have noticed that when a CTE is passed to the `select=...` argument as in `with_cte(cte, select=cte)`, the `.queryset()` call is optional and may be omitted. ## Experimental: Left Outer Join Django does not provide precise control over joins, but there is an experimental way to perform a `LEFT OUTER JOIN` with a CTE query using the `_join_type` keyword argument of `CTE.join(...)`. ```py from django.db.models.sql.constants import LOUTER totals = CTE( Order.objects .values("region_id") .annotate(total=Sum("amount")) .filter(total__gt=100) ) orders = with_cte( totals, select=totals .join(Order, region=totals.col.region_id, _join_type=LOUTER) .annotate(region_total=totals.col.total) ) ``` Which produces the following SQL ```sql WITH RECURSIVE "cte" AS ( SELECT "orders"."region_id", SUM("orders"."amount") AS "total" FROM "orders" GROUP BY "orders"."region_id" HAVING SUM("orders"."amount") > 100 ) SELECT "orders"."id", "orders"."region_id", "orders"."amount", "cte"."total" AS "region_total" FROM "orders" LEFT OUTER JOIN "cte" ON "orders"."region_id" = "cte"."region_id" ``` WARNING: as noted, this feature is experimental. There may be scenarios where Django automatically converts a `LEFT OUTER JOIN` to an `INNER JOIN` in the process of building the query. Be sure to test your queries to ensure they produce the desired SQL. ## Materialized CTE Both PostgreSQL 12+ and sqlite 3.35+ supports `MATERIALIZED` keyword for CTE queries. To enforce usage of this keyword add `materialized` as a parameter of `CTE(..., materialized=True)`. ```py cte = CTE( Order.objects.values('id'), materialized=True ) ``` Which produces this SQL: ```sql WITH RECURSIVE "cte" AS MATERIALIZED ( SELECT "orders"."id" FROM "orders" ) ... ``` ## Raw CTE SQL Some queries are easier to construct with raw SQL than with the Django ORM. `raw_cte_sql()` is one solution for situations like that. The down-side is that each result field in the raw query must be explicitly mapped to a field type. The up-side is that there is no need to compromise result-set expressiveness with the likes of `Manager.raw()`. A short example: ```py from django.db.models import IntegerField, TextField from django_cte.raw import raw_cte_sql cte = CTE(raw_cte_sql( """ SELECT region_id, AVG(amount) AS avg_order FROM orders WHERE region_id = %s GROUP BY region_id """, ["moon"], { "region_id": TextField(), "avg_order": IntegerField(), }, )) moon_avg = with_cte( cte, select=cte .join(Region, name=cte.col.region_id) .annotate(avg_order=cte.col.avg_order) ) ``` Which produces this SQL: ```sql WITH RECURSIVE "cte" AS ( SELECT region_id, AVG(amount) AS avg_order FROM orders WHERE region_id = 'moon' GROUP BY region_id ) SELECT "region"."name", "region"."parent_id", "cte"."avg_order" AS "avg_order" FROM "region" INNER JOIN "cte" ON "region"."name" = "cte"."region_id" ``` **WARNING**: Be very careful when writing raw SQL. Use bind parameters to prevent SQL injection attacks. ## More Advanced Use Cases A few more advanced techniques as well as example query results can be found in the tests: - [`test_cte.py`](https://github.com/dimagi/django-cte/blob/main/tests/test_cte.py) - [`test_recursive.py`](https://github.com/dimagi/django-cte/blob/main/tests/test_recursive.py) - [`test_raw.py`](https://github.com/dimagi/django-cte/blob/main/tests/test_raw.py) ## Appendix A: Model definitions used in sample code ```py class Order(Model): id = AutoField(primary_key=True) region = ForeignKey("Region", on_delete=CASCADE) amount = IntegerField(default=0) class Meta: db_table = "orders" class Region(Model): name = TextField(primary_key=True) parent = ForeignKey("self", null=True, on_delete=CASCADE) class Meta: db_table = "region" ``` ## Appendix B: django-cte v1 documentation (DEPRECATED) The syntax for constructing CTE queries changed slightly in django-cte 2.0. The most important change is that a custom model manager is no longer required on models used to construct CTE queries. The documentation has been updated to use v2 syntax, but the [documentation for v1](https://github.com/dimagi/django-cte/blob/v1.3.3/docs/index.md) can be found on Github if needed. django-cte-2.0.0/pyproject.toml000066400000000000000000000031371502403364700164500ustar00rootroot00000000000000[project] name = "django-cte" description = "Common Table Expressions (CTE) for Django" authors = [{name = "Daniel Miller", email = "millerdev@gmail.com"}] license = {file = "LICENSE"} readme = {file = "README.md", content-type = "text/markdown"} dynamic = ["version"] requires-python = ">= 3.9" # Python and Django versions are read from this file by GitHub Actions. # Precise formatting is important. classifiers = [ "Development Status :: 5 - Production/Stable", 'Environment :: Web Environment', 'Framework :: Django', 'Intended Audience :: Developers', 'License :: OSI Approved :: BSD License', 'Operating System :: OS Independent', 'Programming Language :: Python', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.12', 'Programming Language :: Python :: 3.13', 'Programming Language :: Python :: 3.14', 'Framework :: Django', 'Framework :: Django :: 4', 'Framework :: Django :: 4.2', 'Framework :: Django :: 5', 'Framework :: Django :: 5.1', 'Framework :: Django :: 5.2', 'Topic :: Software Development :: Libraries :: Python Modules', ] dependencies = ["django"] [dependency-groups] dev = [ "psycopg2-binary", "pytest-unmagic", "ruff", ] [project.urls] Home = "https://github.com/dimagi/django-cte" [build-system] requires = ["flit_core >=3.2,<4"] build-backend = "flit_core.buildapi" [tool.flit.module] name = "django_cte" [tool.distutils.bdist_wheel] universal = true django-cte-2.0.0/tests/000077500000000000000000000000001502403364700146725ustar00rootroot00000000000000django-cte-2.0.0/tests/__init__.py000066400000000000000000000014551502403364700170100ustar00rootroot00000000000000import os import warnings from contextlib import contextmanager import django from unmagic import fixture # django setup must occur before importing models os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") django.setup() from .django_setup import init_db, destroy_db # noqa @fixture(autouse=__file__, scope="package") def test_db(): with ignore_v1_warnings(): init_db() yield destroy_db() @contextmanager def ignore_v1_warnings(): msg = ( r"CTE(Manager|QuerySet) is deprecated.*" r"|" r"Use `django_cte\.with_cte\(.*\)` instead\." r"|" r"Use `django_cte\.CTE(\.recursive)?` instead\." ) with warnings.catch_warnings(): warnings.filterwarnings("ignore", message=msg, category=DeprecationWarning) yield django-cte-2.0.0/tests/django_setup.py000066400000000000000000000035321502403364700177310ustar00rootroot00000000000000from django.db import connection from .models import KeyPair, Region, Order is_initialized = False def init_db(): global is_initialized if is_initialized: return is_initialized = True connection.creation.create_test_db(verbosity=0, autoclobber=True) setup_data() def destroy_db(): connection.creation.destroy_test_db(verbosity=0) def setup_data(): regions = {None: None} for name, parent in [ ("sun", None), ("mercury", "sun"), ("venus", "sun"), ("earth", "sun"), ("moon", "earth"), ("mars", "sun"), ("deimos", "mars"), ("phobos", "mars"), ("proxima centauri", None), ("proxima centauri b", "proxima centauri"), ("bernard's star", None), ]: region = Region(name=name, parent=regions[parent]) region.save() regions[name] = region for region, amount in [ ("sun", 1000), ("mercury", 10), ("mercury", 11), ("mercury", 12), ("venus", 20), ("venus", 21), ("venus", 22), ("venus", 23), ("earth", 30), ("earth", 31), ("earth", 32), ("earth", 33), ("moon", 1), ("moon", 2), ("moon", 3), ("mars", 40), ("mars", 41), ("mars", 42), ("proxima centauri", 2000), ("proxima centauri b", 10), ("proxima centauri b", 11), ("proxima centauri b", 12), ]: order = Order(amount=amount, region=regions[region]) order.save() for key, value, parent in [ ("level 1", 1, None), ("level 2", 1, "level 1"), ("level 2", 2, "level 1"), ("level 3", 1, "level 2"), ]: parent = parent and KeyPair.objects.filter(key=parent).first() KeyPair.objects.create(key=key, value=value, parent=parent) django-cte-2.0.0/tests/models.py000066400000000000000000000025041502403364700165300ustar00rootroot00000000000000from django.db.models import ( CASCADE, Manager, Model, QuerySet, AutoField, CharField, ForeignKey, IntegerField, TextField, ) class LT40QuerySet(QuerySet): def lt40(self): return self.filter(amount__lt=40) class LT25QuerySet(QuerySet): def lt25(self): return self.filter(amount__lt=25) class Region(Model): name = TextField(primary_key=True) parent = ForeignKey("self", null=True, on_delete=CASCADE) class Meta: db_table = "region" class User(Model): id = AutoField(primary_key=True) name = TextField() class Meta: db_table = "user" class Order(Model): id = AutoField(primary_key=True) region = ForeignKey(Region, on_delete=CASCADE) amount = IntegerField(default=0) user = ForeignKey(User, null=True, on_delete=CASCADE) class Meta: db_table = "orders" class OrderFromLT40(Order): class Meta: proxy = True objects = Manager.from_queryset(LT40QuerySet)() class OrderCustomManagerNQuery(Order): class Meta: proxy = True objects = Manager.from_queryset(LT25QuerySet)() class KeyPair(Model): key = CharField(max_length=32) value = IntegerField(default=0) parent = ForeignKey("self", null=True, on_delete=CASCADE) class Meta: db_table = "keypair" django-cte-2.0.0/tests/settings.py000066400000000000000000000007221502403364700171050ustar00rootroot00000000000000import os import json if "DB_SETTINGS" in os.environ: _db_settings = json.loads(os.environ["DB_SETTINGS"]) else: # sqlite3 by default # must be sqlite3 >= 3.8.3 supporting WITH clause # must be sqlite3 >= 3.35.0 supporting MATERIALIZED option _db_settings = { "ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:", } DATABASES = {'default': _db_settings} INSTALLED_APPS = ["tests"] SECRET_KEY = "test" USE_TZ = False django-cte-2.0.0/tests/test_combinators.py000066400000000000000000000202131502403364700206210ustar00rootroot00000000000000import pytest from django.db.models import Value from django.db.models.aggregates import Sum from django.test import TestCase from django_cte import CTE, with_cte from .models import Order class TestCTECombinators(TestCase): def test_cte_union_query(self): one = CTE( Order.objects .values("region_id") .annotate(total=Sum("amount")), name="one" ) two = CTE( Order.objects .values("region_id") .annotate(total=Sum("amount") * 2), name="two" ) earths = with_cte( one, select=one.join( Order.objects.filter(region_id="earth"), region=one.col.region_id ) .annotate(region_total=one.col.total) .values_list("amount", "region_id", "region_total") ) mars = with_cte( two, select=two.join( Order.objects.filter(region_id="mars"), region=two.col.region_id ) .annotate(region_total=two.col.total) .values_list("amount", "region_id", "region_total") ) combined = earths.union(mars, all=True) print(combined.query) self.assertEqual(sorted(combined), [ (30, 'earth', 126), (31, 'earth', 126), (32, 'earth', 126), (33, 'earth', 126), (40, 'mars', 246), (41, 'mars', 246), (42, 'mars', 246), ]) # queries used in union should still work on their own print(earths.query) self.assertEqual(sorted(earths),[ (30, 'earth', 126), (31, 'earth', 126), (32, 'earth', 126), (33, 'earth', 126), ]) print(mars.query) self.assertEqual(sorted(mars),[ (40, 'mars', 246), (41, 'mars', 246), (42, 'mars', 246), ]) def test_cte_union_with_non_cte_query(self): one = CTE( Order.objects .values("region_id") .annotate(total=Sum("amount")), ) earths = with_cte( one, select=one.join( Order.objects.filter(region_id="earth"), region=one.col.region_id ).annotate(region_total=one.col.total) ) plain_mars = ( Order.objects.filter(region_id="mars") .annotate(region_total=Value(0)) ) # Note: this does not work in the opposite order. A CTE query # must come first to invoke custom CTE combinator logic. combined = earths.union(plain_mars, all=True) \ .values_list("amount", "region_id", "region_total") print(combined.query) self.assertEqual(sorted(combined), [ (30, 'earth', 126), (31, 'earth', 126), (32, 'earth', 126), (33, 'earth', 126), (40, 'mars', 0), (41, 'mars', 0), (42, 'mars', 0), ]) def test_cte_union_with_duplicate_names(self): cte_sun = CTE( Order.objects .filter(region__parent="sun") .values("region_id") .annotate(total=Sum("amount")), ) cte_proxima = CTE( Order.objects .filter(region__parent="proxima centauri") .values("region_id") .annotate(total=2 * Sum("amount")), ) orders_sun = with_cte( cte_sun, select=cte_sun.join(Order, region=cte_sun.col.region_id) .annotate(region_total=cte_sun.col.total) ) orders_proxima = with_cte( cte_proxima, select=cte_proxima.join(Order, region=cte_proxima.col.region_id) .annotate(region_total=cte_proxima.col.total) ) msg = "Found two or more CTEs named 'cte'" with pytest.raises(ValueError, match=msg): orders_sun.union(orders_proxima) def test_cte_union_of_same_cte(self): cte = CTE( Order.objects .filter(region__parent="sun") .values("region_id") .annotate(total=Sum("amount")), ) orders_big = with_cte( cte, select=cte.join(Order, region=cte.col.region_id) .annotate(region_total=3 * cte.col.total) ) orders_small = with_cte( cte, select=cte.join(Order, region=cte.col.region_id) .annotate(region_total=cte.col.total) ) orders = orders_big.union(orders_small) \ .values_list("amount", "region_id", "region_total") print(orders.query) self.assertEqual(sorted(orders), [ (10, 'mercury', 33), (10, 'mercury', 99), (11, 'mercury', 33), (11, 'mercury', 99), (12, 'mercury', 33), (12, 'mercury', 99), (20, 'venus', 86), (20, 'venus', 258), (21, 'venus', 86), (21, 'venus', 258), (22, 'venus', 86), (22, 'venus', 258), (23, 'venus', 86), (23, 'venus', 258), (30, 'earth', 126), (30, 'earth', 378), (31, 'earth', 126), (31, 'earth', 378), (32, 'earth', 126), (32, 'earth', 378), (33, 'earth', 126), (33, 'earth', 378), (40, 'mars', 123), (40, 'mars', 369), (41, 'mars', 123), (41, 'mars', 369), (42, 'mars', 123), (42, 'mars', 369) ]) def test_cte_intersection(self): cte_big = CTE( Order.objects .values("region_id") .annotate(total=Sum("amount")), name='big' ) cte_small = CTE( Order.objects .values("region_id") .annotate(total=Sum("amount")), name='small' ) orders_big = with_cte( cte_big, select=cte_big.join(Order, region=cte_big.col.region_id) .annotate(region_total=cte_big.col.total) .filter(region_total__gte=86) ) orders_small = with_cte( cte_small, select=cte_small.join(Order, region=cte_small.col.region_id) .annotate(region_total=cte_small.col.total) .filter(region_total__lte=123) ) orders = orders_small.intersection(orders_big) \ .values_list("amount", "region_id", "region_total") print(orders.query) self.assertEqual(sorted(orders), [ (20, 'venus', 86), (21, 'venus', 86), (22, 'venus', 86), (23, 'venus', 86), (40, 'mars', 123), (41, 'mars', 123), (42, 'mars', 123), ]) def test_cte_difference(self): cte_big = CTE( Order.objects .values("region_id") .annotate(total=Sum("amount")), name='big' ) cte_small = CTE( Order.objects .values("region_id") .annotate(total=Sum("amount")), name='small' ) orders_big = with_cte( cte_big, select=cte_big.join(Order, region=cte_big.col.region_id) .annotate(region_total=cte_big.col.total) .filter(region_total__gte=86) ) orders_small = with_cte( cte_small, select=cte_small.join(Order, region=cte_small.col.region_id) .annotate(region_total=cte_small.col.total) .filter(region_total__lte=123) ) orders = orders_small.difference(orders_big) \ .values_list("amount", "region_id", "region_total") print(orders.query) self.assertEqual(sorted(orders), [ (1, 'moon', 6), (2, 'moon', 6), (3, 'moon', 6), (10, 'mercury', 33), (10, 'proxima centauri b', 33), (11, 'mercury', 33), (11, 'proxima centauri b', 33), (12, 'mercury', 33), (12, 'proxima centauri b', 33), ]) django-cte-2.0.0/tests/test_cte.py000066400000000000000000000517771502403364700170770ustar00rootroot00000000000000import pytest from django.db.models import IntegerField, TextField from django.db.models.aggregates import Count, Max, Min, Sum from django.db.models.expressions import ( Exists, ExpressionWrapper, F, OuterRef, Subquery, ) from django.db.models.sql.constants import LOUTER from django.db.utils import OperationalError, ProgrammingError from django.test import TestCase from django_cte import CTE, with_cte from .models import Order, Region, User int_field = IntegerField() text_field = TextField() class TestCTE(TestCase): def test_simple_cte_query(self): cte = CTE( Order.objects .values("region_id") .annotate(total=Sum("amount")) ) orders = with_cte( # WITH cte ... cte, # SELECT ... FROM orders # INNER JOIN cte ON orders.region_id = cte.region_id select=cte.join(Order, region=cte.col.region_id), ).annotate(region_total=cte.col.total) print(orders.query) data = sorted((o.amount, o.region_id, o.region_total) for o in orders) self.assertEqual(data, [ (1, 'moon', 6), (2, 'moon', 6), (3, 'moon', 6), (10, 'mercury', 33), (10, 'proxima centauri b', 33), (11, 'mercury', 33), (11, 'proxima centauri b', 33), (12, 'mercury', 33), (12, 'proxima centauri b', 33), (20, 'venus', 86), (21, 'venus', 86), (22, 'venus', 86), (23, 'venus', 86), (30, 'earth', 126), (31, 'earth', 126), (32, 'earth', 126), (33, 'earth', 126), (40, 'mars', 123), (41, 'mars', 123), (42, 'mars', 123), (1000, 'sun', 1000), (2000, 'proxima centauri', 2000), ]) def test_cte_name_escape(self): totals = CTE( Order.objects .filter(region__parent="sun") .values("region_id") .annotate(total=Sum("amount")), name="mixedCaseCTEName" ) orders = with_cte( totals, select=totals.join(Order, region=totals.col.region_id) .annotate(region_total=totals.col.total) .order_by("amount") ) self.assertTrue( str(orders.query).startswith('WITH RECURSIVE "mixedCaseCTEName"')) def test_cte_queryset(self): sub_totals = CTE( Order.objects .values(region_parent=F("region__parent_id")) .annotate(total=Sum("amount")), ) regions = with_cte( sub_totals, select=Region.objects.annotate( child_regions_total=Subquery( sub_totals.queryset() .filter(region_parent=OuterRef("name")) .values("total"), ), ) .order_by("name") ) print(regions.query) data = [(r.name, r.child_regions_total) for r in regions] self.assertEqual(data, [ ("bernard's star", None), ('deimos', None), ('earth', 6), ('mars', None), ('mercury', None), ('moon', None), ('phobos', None), ('proxima centauri', 33), ('proxima centauri b', None), ('sun', 368), ('venus', None) ]) def test_cte_queryset_with_model_result(self): cte = CTE( Order.objects .annotate(region_parent=F("region__parent_id")), ) orders = with_cte( cte, # WITH cte AS (...) select=cte, # SELECT ... FROM cte ) print(orders.query) data = sorted( (x.region_id, x.amount, x.region_parent) for x in orders)[:5] self.assertEqual(data, [ ("earth", 30, "sun"), ("earth", 31, "sun"), ("earth", 32, "sun"), ("earth", 33, "sun"), ("mars", 40, "sun"), ]) self.assertTrue( all(isinstance(x, Order) for x in orders), repr([x for x in orders]), ) def test_cte_queryset_with_join(self): cte = CTE( Order.objects .annotate(region_parent=F("region__parent_id")), ) orders = with_cte( cte, select=cte.queryset() .annotate(parent=F("region__parent_id")) .order_by("region_id", "amount") ) print(orders.query) data = [(x.region_id, x.region_parent, x.parent) for x in orders][:5] self.assertEqual(data, [ ("earth", "sun", "sun"), ("earth", "sun", "sun"), ("earth", "sun", "sun"), ("earth", "sun", "sun"), ("mars", "sun", "sun"), ]) def test_cte_queryset_with_values_result(self): cte = CTE( Order.objects .values( "region_id", region_parent=F("region__parent_id"), ) .distinct() ) values = with_cte(cte, select=cte).filter(region_parent__isnull=False) print(values.query) def key(item): return item["region_parent"], item["region_id"] data = sorted(values, key=key)[:5] self.assertEqual(data, [ {'region_id': 'moon', 'region_parent': 'earth'}, { 'region_id': 'proxima centauri b', 'region_parent': 'proxima centauri', }, {'region_id': 'earth', 'region_parent': 'sun'}, {'region_id': 'mars', 'region_parent': 'sun'}, {'region_id': 'mercury', 'region_parent': 'sun'}, ]) def test_named_simple_ctes(self): totals = CTE( Order.objects .filter(region__parent="sun") .values("region_id") .annotate(total=Sum("amount")), name="totals", ) region_count = CTE( Region.objects .filter(parent="sun") .values("parent_id") .annotate(num=Count("name")), name="region_count", ) orders = with_cte( totals, region_count, select=region_count.join( totals.join(Order, region=totals.col.region_id), region__parent=region_count.col.parent_id ) .annotate(region_total=totals.col.total) .annotate(region_count=region_count.col.num) .order_by("amount") ) print(orders.query) data = [( o.amount, o.region_id, o.region_count, o.region_total, ) for o in orders] self.assertEqual(data, [ (10, 'mercury', 4, 33), (11, 'mercury', 4, 33), (12, 'mercury', 4, 33), (20, 'venus', 4, 86), (21, 'venus', 4, 86), (22, 'venus', 4, 86), (23, 'venus', 4, 86), (30, 'earth', 4, 126), (31, 'earth', 4, 126), (32, 'earth', 4, 126), (33, 'earth', 4, 126), (40, 'mars', 4, 123), (41, 'mars', 4, 123), (42, 'mars', 4, 123), ]) def test_named_ctes(self): def make_root_mapping(rootmap): return Region.objects.filter( parent__isnull=True ).values( "name", root=F("name"), ).union( rootmap.join(Region, parent=rootmap.col.name).values( "name", root=rootmap.col.root, ), all=True, ) rootmap = CTE.recursive(make_root_mapping, name="rootmap") totals = CTE( rootmap.join(Order, region_id=rootmap.col.name) .values( root=rootmap.col.root, ).annotate( orders_count=Count("id"), region_total=Sum("amount"), ), name="totals", ) root_regions = with_cte( rootmap, totals, select=totals.join(Region, name=totals.col.root).annotate( # count of orders in this region and all subregions orders_count=totals.col.orders_count, # sum of order amounts in this region and all subregions region_total=totals.col.region_total, ) ) print(root_regions.query) data = sorted( (r.name, r.orders_count, r.region_total) for r in root_regions ) self.assertEqual(data, [ ('proxima centauri', 4, 2033), ('sun', 18, 1374), ]) def test_materialized_option(self): totals = CTE( Order.objects .filter(region__parent="sun") .values("region_id") .annotate(total=Sum("amount")), materialized=True ) orders = with_cte( totals, select=totals.join(Order, region=totals.col.region_id) .annotate(region_total=totals.col.total) .order_by("amount") ) self.assertTrue( str(orders.query).startswith( 'WITH RECURSIVE "cte" AS MATERIALIZED' ) ) def test_update_cte_query(self): cte = CTE( Order.objects .values(region_parent=F("region__parent_id")) .annotate(total=Sum("amount")) .filter(total__isnull=False) ) # not the most efficient query, but it exercises CTEUpdateQuery with_cte(cte, select=Order).filter(region_id__in=Subquery( cte.queryset() .filter(region_parent=OuterRef("region_id")) .values("region_parent") )).update(amount=Subquery( cte.queryset() .filter(region_parent=OuterRef("region_id")) .values("total") )) data = set((o.region_id, o.amount) for o in Order.objects.filter( region_id__in=["earth", "sun", "proxima centauri", "mars"] )) self.assertEqual(data, { ('earth', 6), ('mars', 40), ('mars', 41), ('mars', 42), ('proxima centauri', 33), ('sun', 368), }) def test_update_with_subquery(self): # Test for issue: https://github.com/dimagi/django-cte/issues/9 # Issue is not reproduced on sqlite3, use postgres to run. # To reproduce the problem it's required to have some join # in the select-query so the compiler will turn it into a subquery. # To add a join use a filter over field of related model orders = Order.objects.filter(region__parent_id='sun') orders.update(amount=0) data = {(order.region_id, order.amount) for order in orders} self.assertEqual(data, { ('mercury', 0), ('venus', 0), ('earth', 0), ('mars', 0), }) @pytest.mark.xfail( reason="this test will not work until `QuerySet.delete` " "(Django method) calls `self.query.chain(sql.DeleteQuery)` " "instead of `sql.DeleteQuery(self.model)`", raises=(OperationalError, ProgrammingError), strict=True, ) def test_delete_cte_query(self): cte = CTE( Order.objects .values(region_parent=F("region__parent_id")) .annotate(total=Sum("amount")) .filter(total__isnull=False) ) with_cte(cte, select=Order).annotate( cte_has_order=Exists( cte.queryset() .values("total") .filter(region_parent=OuterRef("region_id")) ) ).filter(cte_has_order=False).delete() data = [(o.region_id, o.amount) for o in Order.objects.all()] self.assertEqual(data, [ ('sun', 1000), ('earth', 30), ('earth', 31), ('earth', 32), ('earth', 33), ('proxima centauri', 2000), ]) def test_outerref_in_cte_query(self): # This query is meant to return the difference between min and max # order of each region, through a subquery min_and_max = CTE( Order.objects .filter(region=OuterRef("pk")) .values('region') # This is to force group by region_id .annotate( amount_min=Min("amount"), amount_max=Max("amount"), ) .values('amount_min', 'amount_max') ) regions = ( Region.objects .annotate( difference=Subquery( with_cte(min_and_max, select=min_and_max) .annotate( difference=ExpressionWrapper( F('amount_max') - F('amount_min'), output_field=int_field, ), ).values('difference')[:1], output_field=IntegerField() ) ) .order_by("name") ) print(regions.query) data = [(r.name, r.difference) for r in regions] self.assertEqual(data, [ ("bernard's star", None), ('deimos', None), ('earth', 3), ('mars', 2), ('mercury', 2), ('moon', 2), ('phobos', None), ('proxima centauri', 0), ('proxima centauri b', 2), ('sun', 0), ('venus', 3) ]) def test_experimental_left_outer_join(self): totals = CTE( Order.objects .values("region_id") .annotate(total=Sum("amount")) .filter(total__gt=100) ) orders = with_cte( totals, select=totals .join(Order, region=totals.col.region_id, _join_type=LOUTER) .annotate(region_total=totals.col.total) ) print(orders.query) self.assertIn("LEFT OUTER JOIN", str(orders.query)) self.assertNotIn("INNER JOIN", str(orders.query)) data = sorted((o.region_id, o.amount, o.region_total) for o in orders) self.assertEqual(data, [ ('earth', 30, 126), ('earth', 31, 126), ('earth', 32, 126), ('earth', 33, 126), ('mars', 40, 123), ('mars', 41, 123), ('mars', 42, 123), ('mercury', 10, None), ('mercury', 11, None), ('mercury', 12, None), ('moon', 1, None), ('moon', 2, None), ('moon', 3, None), ('proxima centauri', 2000, 2000), ('proxima centauri b', 10, None), ('proxima centauri b', 11, None), ('proxima centauri b', 12, None), ('sun', 1000, 1000), ('venus', 20, None), ('venus', 21, None), ('venus', 22, None), ('venus', 23, None), ]) def test_non_cte_subquery(self): """ Verifies that subquery annotations are handled correctly when the subquery model doesn't use the CTE manager, and the query results match expected behavior """ sub_totals = CTE( Order.objects .values(region_parent=F("region__parent_id")) .annotate( total=Sum("amount"), # trivial subquery example testing existence of # a user for the order non_cte_subquery=Exists( User.objects.filter(pk=OuterRef("user_id")) ), ), ) regions = with_cte( sub_totals, select=Region.objects.annotate( child_regions_total=Subquery( sub_totals.queryset() .filter(region_parent=OuterRef("name")) .values("total"), ), ) .order_by("name") ) print(regions.query) data = [(r.name, r.child_regions_total) for r in regions] self.assertEqual(data, [ ("bernard's star", None), ('deimos', None), ('earth', 6), ('mars', None), ('mercury', None), ('moon', None), ('phobos', None), ('proxima centauri', 33), ('proxima centauri b', None), ('sun', 368), ('venus', None) ]) def test_explain(self): """ Verifies that using .explain() prepends the EXPLAIN clause in the correct position """ totals = CTE( Order.objects .filter(region__parent="sun") .values("region_id") .annotate(total=Sum("amount")), name="totals", ) region_count = CTE( Region.objects .filter(parent="sun") .values("parent_id") .annotate(num=Count("name")), name="region_count", ) orders = with_cte( totals, region_count, select=region_count.join( totals.join(Order, region=totals.col.region_id), region__parent=region_count.col.parent_id ) .annotate(region_total=totals.col.total) .annotate(region_count=region_count.col.num) .order_by("amount") ) print(orders.query) self.assertIsInstance(orders.explain(), str) def test_empty_result_set_cte(self): """ Verifies that the CTEQueryCompiler can handle empty result sets in the related CTEs """ totals = CTE( Order.objects .filter(id__in=[]) .values("region_id") .annotate(total=Sum("amount")), name="totals", ) orders = with_cte( totals, select=totals.join(Order, region=totals.col.region_id) .annotate(region_total=totals.col.total) .order_by("amount") ) self.assertEqual(len(orders), 0) def test_left_outer_join_on_empty_result_set_cte(self): totals = CTE( Order.objects .filter(id__in=[]) .values("region_id") .annotate(total=Sum("amount")), name="totals", ) orders = with_cte( totals, select=totals .join(Order, region=totals.col.region_id, _join_type=LOUTER) .annotate(region_total=totals.col.total) .order_by("amount") ) self.assertEqual(len(orders), 22) def test_union_query_with_cte(self): orders = ( Order.objects .filter(region__parent="sun") .only("region", "amount") ) orders_cte = CTE(orders, name="orders_cte") orders_cte_queryset = orders_cte.queryset() earth_orders = orders_cte_queryset.filter(region="earth") mars_orders = orders_cte_queryset.filter(region="mars") earth_mars = earth_orders.union(mars_orders, all=True) earth_mars_cte = with_cte( orders_cte, select=earth_mars .order_by("region", "amount") .values_list("region", "amount") ) print(earth_mars_cte.query) self.assertEqual(list(earth_mars_cte), [ ('earth', 30), ('earth', 31), ('earth', 32), ('earth', 33), ('mars', 40), ('mars', 41), ('mars', 42), ]) def test_cte_select_pk(self): orders = Order.objects.filter(region="earth").values("pk") cte = CTE(orders) queryset = with_cte( cte, select=cte.join(orders, pk=cte.col.pk) ).order_by("pk") print(queryset.query) self.assertEqual(list(queryset), [ {'pk': 9}, {'pk': 10}, {'pk': 11}, {'pk': 12}, ]) def test_django52_resolve_ref_regression(self): cte = CTE( Order.objects.annotate( pnt_id=F("region__parent_id"), region_name=F("region__name"), ).values( # important: more than one query.select field "region_id", "amount", # important: more than one query.annotations field "pnt_id", "region_name", ) ) qs = with_cte( cte, select=cte.queryset() .values( amt=cte.col.amount, pnt_id=cte.col.pnt_id, region_name=cte.col.region_name, ) .filter(region_id="earth") .order_by("amount") ) print(qs.query) self.assertEqual(list(qs), [ {'amt': 30, 'region_name': 'earth', 'pnt_id': 'sun'}, {'amt': 31, 'region_name': 'earth', 'pnt_id': 'sun'}, {'amt': 32, 'region_name': 'earth', 'pnt_id': 'sun'}, {'amt': 33, 'region_name': 'earth', 'pnt_id': 'sun'}, ]) django-cte-2.0.0/tests/test_django.py000066400000000000000000000031271502403364700175500ustar00rootroot00000000000000from unittest import SkipTest import django from django.db import OperationalError, ProgrammingError from django.db.models import Window from django.db.models.functions import Rank from django.test import TestCase from django_cte import CTE, with_cte from .models import Order, Region class WindowFunctions(TestCase): def test_heterogeneous_filter_in_cte(self): if django.VERSION < (4, 2): raise SkipTest("feature added in Django 4.2") cte = CTE( Order.objects.annotate( region_amount_rank=Window( Rank(), partition_by="region_id", order_by="-amount" ), ) .order_by("region_id") .values("region_id", "region_amount_rank") .filter(region_amount_rank=1, region_id__in=["sun", "moon"]) ) qs = with_cte(cte, select=cte.join(Region, name=cte.col.region_id)) print(qs.query) # ProgrammingError: column cte.region_id does not exist # WITH RECURSIVE "cte" AS (SELECT * FROM ( # SELECT "orders"."region_id" AS "col1", ... # "region" INNER JOIN "cte" ON "region"."name" = ("cte"."region_id") try: self.assertEqual({r.name for r in qs}, {"moon", "sun"}) except (OperationalError, ProgrammingError) as err: if "cte.region_id" in str(err): raise SkipTest( "window function auto-aliasing breaks CTE " "column references" ) raise if django.VERSION < (5, 2): assert 0, "unexpected pass" django-cte-2.0.0/tests/test_manager.py000066400000000000000000000044251502403364700177220ustar00rootroot00000000000000from django.db.models.expressions import F from django.test import TestCase from django_cte import CTE, with_cte from .models import ( OrderFromLT40, OrderCustomManagerNQuery, LT40QuerySet, ) class TestCTE(TestCase): def test_cte_queryset_with_from_queryset(self): self.assertEqual(type(OrderFromLT40.objects.all()), LT40QuerySet) cte = CTE( OrderFromLT40.objects .annotate(region_parent=F("region__parent_id")) .filter(region__parent_id="sun") ) orders = with_cte( cte, select=cte.queryset() .lt40() # custom queryset method .order_by("region_id", "amount") ) print(orders.query) data = [(x.region_id, x.amount, x.region_parent) for x in orders] self.assertEqual(data, [ ("earth", 30, "sun"), ("earth", 31, "sun"), ("earth", 32, "sun"), ("earth", 33, "sun"), ('mercury', 10, 'sun'), ('mercury', 11, 'sun'), ('mercury', 12, 'sun'), ('venus', 20, 'sun'), ('venus', 21, 'sun'), ('venus', 22, 'sun'), ('venus', 23, 'sun'), ]) def test_cte_queryset_with_custom_queryset(self): cte = CTE( OrderCustomManagerNQuery.objects .annotate(region_parent=F("region__parent_id")) .filter(region__parent_id="sun") ) orders = with_cte( cte, select=cte.queryset() .lt25() # custom queryset method .order_by("region_id", "amount") ) print(orders.query) data = [(x.region_id, x.amount, x.region_parent) for x in orders] self.assertEqual(data, [ ('mercury', 10, 'sun'), ('mercury', 11, 'sun'), ('mercury', 12, 'sun'), ('venus', 20, 'sun'), ('venus', 21, 'sun'), ('venus', 22, 'sun'), ('venus', 23, 'sun'), ]) def test_cte_queryset_with_deferred_loading(self): cte = CTE( OrderCustomManagerNQuery.objects.order_by("id").only("id")[:1] ) orders = with_cte(cte, select=cte) print(orders.query) self.assertEqual([x.id for x in orders], [1]) django-cte-2.0.0/tests/test_raw.py000066400000000000000000000031331502403364700170740ustar00rootroot00000000000000from django.db.models import IntegerField, TextField from django.test import TestCase from django_cte import CTE, with_cte from django_cte.raw import raw_cte_sql from .models import Region int_field = IntegerField() text_field = TextField() class TestRawCTE(TestCase): def test_raw_cte_sql(self): cte = CTE(raw_cte_sql( """ SELECT region_id, AVG(amount) AS avg_order FROM orders WHERE region_id = %s GROUP BY region_id """, ["moon"], {"region_id": text_field, "avg_order": int_field}, )) moon_avg = with_cte( cte, select=cte.join(Region, name=cte.col.region_id) ).annotate(avg_order=cte.col.avg_order) print(moon_avg.query) data = [(r.name, r.parent.name, r.avg_order) for r in moon_avg] self.assertEqual(data, [('moon', 'earth', 2)]) def test_raw_cte_sql_name_escape(self): cte = CTE( raw_cte_sql( """ SELECT region_id, AVG(amount) AS avg_order FROM orders WHERE region_id = %s GROUP BY region_id """, ["moon"], {"region_id": text_field, "avg_order": int_field}, ), name="mixedCaseCTEName" ) moon_avg = with_cte( cte, select=cte.join(Region, name=cte.col.region_id) ).annotate(avg_order=cte.col.avg_order) self.assertTrue( str(moon_avg.query).startswith( 'WITH RECURSIVE "mixedCaseCTEName"') ) django-cte-2.0.0/tests/test_recursive.py000066400000000000000000000260311502403364700203140ustar00rootroot00000000000000import pickle from unittest import SkipTest from django.db.models import IntegerField, TextField from django.db.models.expressions import ( Case, Exists, ExpressionWrapper, F, OuterRef, Q, Value, When, ) from django.db.models.functions import Concat from django.db.utils import DatabaseError from django.test import TestCase from django_cte import CTE, with_cte from .models import KeyPair, Region int_field = IntegerField() text_field = TextField() class TestRecursiveCTE(TestCase): def test_recursive_cte_query(self): def make_regions_cte(cte): return Region.objects.filter( # non-recursive: get root nodes parent__isnull=True ).values( "name", path=F("name"), depth=Value(0, output_field=int_field), ).union( # recursive union: get descendants cte.join(Region, parent=cte.col.name).values( "name", path=Concat( cte.col.path, Value(" / "), F("name"), output_field=text_field, ), depth=cte.col.depth + Value(1, output_field=int_field), ), all=True, ) cte = CTE.recursive(make_regions_cte) regions = with_cte( cte, select=cte.join(Region, name=cte.col.name) .annotate( path=cte.col.path, depth=cte.col.depth, ) .filter(depth=2) .order_by("path") ) print(regions.query) data = [(r.name, r.path, r.depth) for r in regions] self.assertEqual(data, [ ('moon', 'sun / earth / moon', 2), ('deimos', 'sun / mars / deimos', 2), ('phobos', 'sun / mars / phobos', 2), ]) def test_recursive_cte_reference_in_condition(self): def make_regions_cte(cte): return Region.objects.filter( parent__isnull=True ).values( "name", path=F("name"), depth=Value(0, output_field=int_field), is_planet=Value(0, output_field=int_field), ).union( cte.join( Region, parent=cte.col.name ).annotate( # annotations for filter and CASE/WHEN conditions parent_name=ExpressionWrapper( cte.col.name, output_field=text_field, ), parent_depth=ExpressionWrapper( cte.col.depth, output_field=int_field, ), ).filter( ~Q(parent_name="mars"), ).values( "name", path=Concat( cte.col.path, Value("\x01"), F("name"), output_field=text_field, ), depth=cte.col.depth + Value(1, output_field=int_field), is_planet=Case( When(parent_depth=0, then=Value(1)), default=Value(0), output_field=int_field, ), ), all=True, ) cte = CTE.recursive(make_regions_cte) regions = with_cte( cte, select=cte.join(Region, name=cte.col.name) ).annotate( path=cte.col.path, depth=cte.col.depth, is_planet=cte.col.is_planet, ).order_by("path") data = [(r.path.split("\x01"), r.is_planet) for r in regions] print(data) self.assertEqual(data, [ (["bernard's star"], 0), (['proxima centauri'], 0), (['proxima centauri', 'proxima centauri b'], 1), (['sun'], 0), (['sun', 'earth'], 1), (['sun', 'earth', 'moon'], 0), (['sun', 'mars'], 1), # mars moons excluded: parent_name != 'mars' (['sun', 'mercury'], 1), (['sun', 'venus'], 1), ]) def test_recursive_cte_with_empty_union_part(self): def make_regions_cte(cte): return Region.objects.none().union( cte.join(Region, parent=cte.col.name), all=True, ) cte = CTE.recursive(make_regions_cte) regions = with_cte(cte, select=cte.join(Region, name=cte.col.name)) print(regions.query) try: self.assertEqual(regions.count(), 0) except DatabaseError: raise SkipTest( "Expected failure: QuerySet omits `EmptyQuerySet` from " "UNION queries resulting in invalid CTE SQL" ) # -- recursive query "cte" does not have the form # -- non-recursive-term UNION [ALL] recursive-term # WITH RECURSIVE cte AS ( # SELECT "tests_region"."name", "tests_region"."parent_id" # FROM "tests_region", "cte" # WHERE "tests_region"."parent_id" = ("cte"."name") # ) # SELECT COUNT(*) # FROM "tests_region", "cte" # WHERE "tests_region"."name" = ("cte"."name") def test_circular_ref_error(self): def make_bad_cte(cte): # NOTE: not a valid recursive CTE query return cte.join(Region, parent=cte.col.name).values( depth=cte.col.depth + 1, ) cte = CTE.recursive(make_bad_cte) regions = with_cte(cte, select=cte.join(Region, name=cte.col.name)) with self.assertRaises(ValueError) as context: print(regions.query) self.assertIn("Circular reference:", str(context.exception)) def test_attname_should_not_mask_col_name(self): def make_regions_cte(cte): return Region.objects.filter( name="moon" ).values( "name", "parent_id", ).union( cte.join(Region, name=cte.col.parent_id).values( "name", "parent_id", ), all=True, ) cte = CTE.recursive(make_regions_cte) regions = with_cte( cte, select=Region.objects.annotate(_ex=Exists( cte.queryset() .values(value=Value("1", output_field=int_field)) .filter(name=OuterRef("name")) )) .filter(_ex=True) .order_by("name") ) print(regions.query) data = [r.name for r in regions] self.assertEqual(data, ['earth', 'moon', 'sun']) def test_pickle_recursive_cte_queryset(self): def make_regions_cte(cte): return Region.objects.filter( parent__isnull=True ).annotate( depth=Value(0, output_field=int_field), ).union( cte.join(Region, parent=cte.col.name).annotate( depth=cte.col.depth + Value(1, output_field=int_field), ), all=True, ) cte = CTE.recursive(make_regions_cte) regions = with_cte(cte, select=cte).filter(depth=2).order_by("name") pickled_qs = pickle.loads(pickle.dumps(regions)) data = [(r.name, r.depth) for r in pickled_qs] self.assertEqual(data, [(r.name, r.depth) for r in regions]) self.assertEqual(data, [('deimos', 2), ('moon', 2), ('phobos', 2)]) def test_alias_change_in_annotation(self): def make_regions_cte(cte): return Region.objects.filter( parent__name="sun", ).annotate( value=F('name'), ).union( cte.join( Region.objects.annotate(value=F('name')), parent_id=cte.col.name, ), all=True, ) cte = CTE.recursive(make_regions_cte) query = with_cte(cte, select=cte) exclude_leaves = CTE(cte.queryset().filter( parent__name='sun', ).annotate( value=Concat(F('name'), F('name')) ), name='value_cte') query = with_cte(exclude_leaves, select=query.annotate( _exclude_leaves=Exists( exclude_leaves.queryset().filter( name=OuterRef("name"), value=OuterRef("value"), ) ) ).filter(_exclude_leaves=True)) print(query.query) # Nothing should be returned. self.assertFalse(query) def test_alias_as_subquery(self): # This test covers CTEColumnRef.relabeled_clone def make_regions_cte(cte): return KeyPair.objects.filter( parent__key="level 1", ).annotate( rank=F('value'), ).union( cte.join( KeyPair.objects.order_by(), parent_id=cte.col.id, ).annotate( rank=F('value'), ), all=True, ) cte = CTE.recursive(make_regions_cte) children = with_cte(cte, select=cte) xdups = CTE(cte.queryset().filter( parent__key="level 1", ).annotate( rank=F('value') ).values('id', 'rank'), name='xdups') children = with_cte(xdups, select=children.annotate( _exclude=Exists( ( xdups.queryset().filter( id=OuterRef("id"), rank=OuterRef("rank"), ) ) ) ).filter(_exclude=True)) print(children.query) query = KeyPair.objects.filter(parent__in=children) print(query.query) print(children.query) self.assertEqual(query.get().key, 'level 3') # Tests the case in which children's query was modified since it was # used in a subquery to define `query` above. self.assertEqual( list(c.key for c in children), ['level 2', 'level 2'] ) def test_materialized(self): # This test covers MATERIALIZED option in SQL query def make_regions_cte(cte): return KeyPair.objects.all() cte = CTE.recursive(make_regions_cte, materialized=True) query = with_cte(cte, select=KeyPair) print(query.query) self.assertTrue( str(query.query).startswith('WITH RECURSIVE "cte" AS MATERIALIZED') ) def test_recursive_self_queryset(self): def make_regions_cte(cte): return Region.objects.filter( pk="earth" ).values("pk").union( cte.join(Region, parent=cte.col.pk).values("pk") ) cte = CTE.recursive(make_regions_cte) queryset = with_cte(cte, select=cte).order_by("pk") print(queryset.query) self.assertEqual(list(queryset), [ {'pk': 'earth'}, {'pk': 'moon'}, ]) django-cte-2.0.0/tests/test_v1/000077500000000000000000000000001502403364700162575ustar00rootroot00000000000000django-cte-2.0.0/tests/test_v1/__init__.py000066400000000000000000000005611502403364700203720ustar00rootroot00000000000000from unmagic import fixture from .. import ignore_v1_warnings @fixture(autouse=__file__) def ignore_v1_deprecations(): with ignore_v1_warnings(): yield @fixture(autouse=__file__, scope="class") def ignore_v1_deprecations_in_class_setup(): with ignore_v1_warnings(): yield with ignore_v1_warnings(): from . import models # noqa: F401 django-cte-2.0.0/tests/test_v1/models.py000066400000000000000000000031731502403364700201200ustar00rootroot00000000000000from django.db.models import Manager from django_cte import CTEManager, CTEQuerySet from ..models import ( KeyPair as V2KeyPair, Order as V2Order, Region as V2Region, User, # noqa: F401 ) class LT40QuerySet(CTEQuerySet): def lt40(self): return self.filter(amount__lt=40) class LT30QuerySet(CTEQuerySet): def lt30(self): return self.filter(amount__lt=30) class LT25QuerySet(CTEQuerySet): def lt25(self): return self.filter(amount__lt=25) class LTManager(CTEManager): pass class V1Region(V2Region): objects = CTEManager() class Meta: proxy = True Region = V1Region class V1Order(V2Order): objects = CTEManager() class Meta: proxy = True Order = V1Order class V1OrderFromLT40(Order): class Meta: proxy = True objects = CTEManager.from_queryset(LT40QuerySet)() class V1OrderLT40AsManager(Order): class Meta: proxy = True objects = LT40QuerySet.as_manager() class V1OrderCustomManagerNQuery(Order): class Meta: proxy = True objects = LTManager.from_queryset(LT25QuerySet)() class V1OrderCustomManager(Order): class Meta: proxy = True objects = LTManager() class V1OrderPlainManager(Order): class Meta: proxy = True objects = Manager() class V1KeyPair(V2KeyPair): objects = CTEManager() class Meta: proxy = True KeyPair = V1KeyPair OrderCustomManager = V1OrderCustomManager OrderCustomManagerNQuery = V1OrderCustomManagerNQuery OrderFromLT40 = V1OrderFromLT40 OrderLT40AsManager = V1OrderLT40AsManager OrderPlainManager = V1OrderPlainManager django-cte-2.0.0/tests/test_v1/test_combinators.py000066400000000000000000000202011502403364700222030ustar00rootroot00000000000000import pytest from django.db.models import Value from django.db.models.aggregates import Sum from django.test import TestCase from django_cte import With from .models import Order, OrderPlainManager class TestCTECombinators(TestCase): def test_cte_union_query(self): one = With( Order.objects .values("region_id") .annotate(total=Sum("amount")), name="one" ) two = With( Order.objects .values("region_id") .annotate(total=Sum("amount") * 2), name="two" ) earths = ( one.join( Order.objects.filter(region_id="earth"), region=one.col.region_id ) .with_cte(one) .annotate(region_total=one.col.total) .values_list("amount", "region_id", "region_total") ) mars = ( two.join( Order.objects.filter(region_id="mars"), region=two.col.region_id ) .with_cte(two) .annotate(region_total=two.col.total) .values_list("amount", "region_id", "region_total") ) combined = earths.union(mars, all=True) print(combined.query) self.assertEqual(sorted(combined), [ (30, 'earth', 126), (31, 'earth', 126), (32, 'earth', 126), (33, 'earth', 126), (40, 'mars', 246), (41, 'mars', 246), (42, 'mars', 246), ]) # queries used in union should still work on their own print(earths.query) self.assertEqual(sorted(earths),[ (30, 'earth', 126), (31, 'earth', 126), (32, 'earth', 126), (33, 'earth', 126), ]) print(mars.query) self.assertEqual(sorted(mars),[ (40, 'mars', 246), (41, 'mars', 246), (42, 'mars', 246), ]) def test_cte_union_with_non_cte_query(self): one = With( Order.objects .values("region_id") .annotate(total=Sum("amount")), ) earths = ( one.join( Order.objects.filter(region_id="earth"), region=one.col.region_id ) .with_cte(one) .annotate(region_total=one.col.total) ) plain_mars = ( OrderPlainManager.objects.filter(region_id="mars") .annotate(region_total=Value(0)) ) # Note: this does not work in the opposite order. A CTE query # must come first to invoke custom CTE combinator logic. combined = earths.union(plain_mars, all=True) \ .values_list("amount", "region_id", "region_total") print(combined.query) self.assertEqual(sorted(combined), [ (30, 'earth', 126), (31, 'earth', 126), (32, 'earth', 126), (33, 'earth', 126), (40, 'mars', 0), (41, 'mars', 0), (42, 'mars', 0), ]) def test_cte_union_with_duplicate_names(self): cte_sun = With( Order.objects .filter(region__parent="sun") .values("region_id") .annotate(total=Sum("amount")), ) cte_proxima = With( Order.objects .filter(region__parent="proxima centauri") .values("region_id") .annotate(total=2 * Sum("amount")), ) orders_sun = ( cte_sun.join(Order, region=cte_sun.col.region_id) .with_cte(cte_sun) .annotate(region_total=cte_sun.col.total) ) orders_proxima = ( cte_proxima.join(Order, region=cte_proxima.col.region_id) .with_cte(cte_proxima) .annotate(region_total=cte_proxima.col.total) ) msg = "Found two or more CTEs named 'cte'" with pytest.raises(ValueError, match=msg): orders_sun.union(orders_proxima) def test_cte_union_of_same_cte(self): cte = With( Order.objects .filter(region__parent="sun") .values("region_id") .annotate(total=Sum("amount")), ) orders_big = ( cte.join(Order, region=cte.col.region_id) .with_cte(cte) .annotate(region_total=3 * cte.col.total) ) orders_small = ( cte.join(Order, region=cte.col.region_id) .with_cte(cte) .annotate(region_total=cte.col.total) ) orders = orders_big.union(orders_small) \ .values_list("amount", "region_id", "region_total") print(orders.query) self.assertEqual(sorted(orders), [ (10, 'mercury', 33), (10, 'mercury', 99), (11, 'mercury', 33), (11, 'mercury', 99), (12, 'mercury', 33), (12, 'mercury', 99), (20, 'venus', 86), (20, 'venus', 258), (21, 'venus', 86), (21, 'venus', 258), (22, 'venus', 86), (22, 'venus', 258), (23, 'venus', 86), (23, 'venus', 258), (30, 'earth', 126), (30, 'earth', 378), (31, 'earth', 126), (31, 'earth', 378), (32, 'earth', 126), (32, 'earth', 378), (33, 'earth', 126), (33, 'earth', 378), (40, 'mars', 123), (40, 'mars', 369), (41, 'mars', 123), (41, 'mars', 369), (42, 'mars', 123), (42, 'mars', 369) ]) def test_cte_intersection(self): cte_big = With( Order.objects .values("region_id") .annotate(total=Sum("amount")), name='big' ) cte_small = With( Order.objects .values("region_id") .annotate(total=Sum("amount")), name='small' ) orders_big = ( cte_big.join(Order, region=cte_big.col.region_id) .with_cte(cte_big) .annotate(region_total=cte_big.col.total) .filter(region_total__gte=86) ) orders_small = ( cte_small.join(Order, region=cte_small.col.region_id) .with_cte(cte_small) .annotate(region_total=cte_small.col.total) .filter(region_total__lte=123) ) orders = orders_small.intersection(orders_big) \ .values_list("amount", "region_id", "region_total") print(orders.query) self.assertEqual(sorted(orders), [ (20, 'venus', 86), (21, 'venus', 86), (22, 'venus', 86), (23, 'venus', 86), (40, 'mars', 123), (41, 'mars', 123), (42, 'mars', 123), ]) def test_cte_difference(self): cte_big = With( Order.objects .values("region_id") .annotate(total=Sum("amount")), name='big' ) cte_small = With( Order.objects .values("region_id") .annotate(total=Sum("amount")), name='small' ) orders_big = ( cte_big.join(Order, region=cte_big.col.region_id) .with_cte(cte_big) .annotate(region_total=cte_big.col.total) .filter(region_total__gte=86) ) orders_small = ( cte_small.join(Order, region=cte_small.col.region_id) .with_cte(cte_small) .annotate(region_total=cte_small.col.total) .filter(region_total__lte=123) ) orders = orders_small.difference(orders_big) \ .values_list("amount", "region_id", "region_total") print(orders.query) self.assertEqual(sorted(orders), [ (1, 'moon', 6), (2, 'moon', 6), (3, 'moon', 6), (10, 'mercury', 33), (10, 'proxima centauri b', 33), (11, 'mercury', 33), (11, 'proxima centauri b', 33), (12, 'mercury', 33), (12, 'proxima centauri b', 33), ]) django-cte-2.0.0/tests/test_v1/test_cte.py000066400000000000000000000476531502403364700204620ustar00rootroot00000000000000from unittest import SkipTest from django.db.models import IntegerField, TextField from django.db.models.aggregates import Count, Max, Min, Sum from django.db.models.expressions import ( Exists, ExpressionWrapper, F, OuterRef, Subquery, ) from django.db.models.sql.constants import LOUTER from django.test import TestCase from django_cte import With from django_cte import CTEManager from .models import Order, Region, User int_field = IntegerField() text_field = TextField() class TestCTE(TestCase): def test_simple_cte_query(self): cte = With( Order.objects .values("region_id") .annotate(total=Sum("amount")) ) orders = ( # FROM orders INNER JOIN cte ON orders.region_id = cte.region_id cte.join(Order, region=cte.col.region_id) # Add `WITH ...` before `SELECT ... FROM orders ...` .with_cte(cte) # Annotate each Order with a "region_total" .annotate(region_total=cte.col.total) ) print(orders.query) data = sorted((o.amount, o.region_id, o.region_total) for o in orders) self.assertEqual(data, [ (1, 'moon', 6), (2, 'moon', 6), (3, 'moon', 6), (10, 'mercury', 33), (10, 'proxima centauri b', 33), (11, 'mercury', 33), (11, 'proxima centauri b', 33), (12, 'mercury', 33), (12, 'proxima centauri b', 33), (20, 'venus', 86), (21, 'venus', 86), (22, 'venus', 86), (23, 'venus', 86), (30, 'earth', 126), (31, 'earth', 126), (32, 'earth', 126), (33, 'earth', 126), (40, 'mars', 123), (41, 'mars', 123), (42, 'mars', 123), (1000, 'sun', 1000), (2000, 'proxima centauri', 2000), ]) def test_cte_name_escape(self): totals = With( Order.objects .filter(region__parent="sun") .values("region_id") .annotate(total=Sum("amount")), name="mixedCaseCTEName" ) orders = ( totals .join(Order, region=totals.col.region_id) .with_cte(totals) .annotate(region_total=totals.col.total) .order_by("amount") ) self.assertTrue( str(orders.query).startswith('WITH RECURSIVE "mixedCaseCTEName"')) def test_cte_queryset(self): sub_totals = With( Order.objects .values(region_parent=F("region__parent_id")) .annotate(total=Sum("amount")), ) regions = ( Region.objects.all() .with_cte(sub_totals) .annotate( child_regions_total=Subquery( sub_totals.queryset() .filter(region_parent=OuterRef("name")) .values("total"), ), ) .order_by("name") ) print(regions.query) data = [(r.name, r.child_regions_total) for r in regions] self.assertEqual(data, [ ("bernard's star", None), ('deimos', None), ('earth', 6), ('mars', None), ('mercury', None), ('moon', None), ('phobos', None), ('proxima centauri', 33), ('proxima centauri b', None), ('sun', 368), ('venus', None) ]) def test_cte_queryset_with_model_result(self): cte = With( Order.objects .annotate(region_parent=F("region__parent_id")), ) orders = cte.queryset().with_cte(cte) print(orders.query) data = sorted( (x.region_id, x.amount, x.region_parent) for x in orders)[:5] self.assertEqual(data, [ ("earth", 30, "sun"), ("earth", 31, "sun"), ("earth", 32, "sun"), ("earth", 33, "sun"), ("mars", 40, "sun"), ]) self.assertTrue( all(isinstance(x, Order) for x in orders), repr([x for x in orders]), ) def test_cte_queryset_with_join(self): cte = With( Order.objects .annotate(region_parent=F("region__parent_id")), ) orders = ( cte.queryset() .with_cte(cte) .annotate(parent=F("region__parent_id")) .order_by("region_id", "amount") ) print(orders.query) data = [(x.region_id, x.region_parent, x.parent) for x in orders][:5] self.assertEqual(data, [ ("earth", "sun", "sun"), ("earth", "sun", "sun"), ("earth", "sun", "sun"), ("earth", "sun", "sun"), ("mars", "sun", "sun"), ]) def test_cte_queryset_with_values_result(self): cte = With( Order.objects .values( "region_id", region_parent=F("region__parent_id"), ) .distinct() ) values = ( cte.queryset() .with_cte(cte) .filter(region_parent__isnull=False) ) print(values.query) def key(item): return item["region_parent"], item["region_id"] data = sorted(values, key=key)[:5] self.assertEqual(data, [ {'region_id': 'moon', 'region_parent': 'earth'}, { 'region_id': 'proxima centauri b', 'region_parent': 'proxima centauri', }, {'region_id': 'earth', 'region_parent': 'sun'}, {'region_id': 'mars', 'region_parent': 'sun'}, {'region_id': 'mercury', 'region_parent': 'sun'}, ]) def test_named_simple_ctes(self): totals = With( Order.objects .filter(region__parent="sun") .values("region_id") .annotate(total=Sum("amount")), name="totals", ) region_count = With( Region.objects .filter(parent="sun") .values("parent_id") .annotate(num=Count("name")), name="region_count", ) orders = ( region_count.join( totals.join(Order, region=totals.col.region_id), region__parent=region_count.col.parent_id ) .with_cte(totals) .with_cte(region_count) .annotate(region_total=totals.col.total) .annotate(region_count=region_count.col.num) .order_by("amount") ) print(orders.query) data = [( o.amount, o.region_id, o.region_count, o.region_total, ) for o in orders] self.assertEqual(data, [ (10, 'mercury', 4, 33), (11, 'mercury', 4, 33), (12, 'mercury', 4, 33), (20, 'venus', 4, 86), (21, 'venus', 4, 86), (22, 'venus', 4, 86), (23, 'venus', 4, 86), (30, 'earth', 4, 126), (31, 'earth', 4, 126), (32, 'earth', 4, 126), (33, 'earth', 4, 126), (40, 'mars', 4, 123), (41, 'mars', 4, 123), (42, 'mars', 4, 123), ]) def test_named_ctes(self): def make_root_mapping(rootmap): return Region.objects.filter( parent__isnull=True ).values( "name", root=F("name"), ).union( rootmap.join(Region, parent=rootmap.col.name).values( "name", root=rootmap.col.root, ), all=True, ) rootmap = With.recursive(make_root_mapping, name="rootmap") totals = With( rootmap.join(Order, region_id=rootmap.col.name) .values( root=rootmap.col.root, ).annotate( orders_count=Count("id"), region_total=Sum("amount"), ), name="totals", ) root_regions = ( totals.join(Region, name=totals.col.root) .with_cte(rootmap) .with_cte(totals) .annotate( # count of orders in this region and all subregions orders_count=totals.col.orders_count, # sum of order amounts in this region and all subregions region_total=totals.col.region_total, ) ) print(root_regions.query) data = sorted( (r.name, r.orders_count, r.region_total) for r in root_regions ) self.assertEqual(data, [ ('proxima centauri', 4, 2033), ('sun', 18, 1374), ]) def test_materialized_option(self): totals = With( Order.objects .filter(region__parent="sun") .values("region_id") .annotate(total=Sum("amount")), materialized=True ) orders = ( totals .join(Order, region=totals.col.region_id) .with_cte(totals) .annotate(region_total=totals.col.total) .order_by("amount") ) self.assertTrue( str(orders.query).startswith( 'WITH RECURSIVE "cte" AS MATERIALIZED' ) ) def test_update_cte_query(self): cte = With( Order.objects .values(region_parent=F("region__parent_id")) .annotate(total=Sum("amount")) .filter(total__isnull=False) ) # not the most efficient query, but it exercises CTEUpdateQuery Order.objects.all().with_cte(cte).filter(region_id__in=Subquery( cte.queryset() .filter(region_parent=OuterRef("region_id")) .values("region_parent") )).update(amount=Subquery( cte.queryset() .filter(region_parent=OuterRef("region_id")) .values("total") )) data = set((o.region_id, o.amount) for o in Order.objects.filter( region_id__in=["earth", "sun", "proxima centauri", "mars"] )) self.assertEqual(data, { ('earth', 6), ('mars', 40), ('mars', 41), ('mars', 42), ('proxima centauri', 33), ('sun', 368), }) def test_update_with_subquery(self): # Test for issue: https://github.com/dimagi/django-cte/issues/9 # Issue is not reproduces on sqlite3 use postgres to run. # To reproduce the problem it's required to have some join # in the select-query so the compiler will turn it into a subquery. # To add a join use a filter over field of related model orders = Order.objects.filter(region__parent_id='sun') orders.update(amount=0) data = {(order.region_id, order.amount) for order in orders} self.assertEqual(data, { ('mercury', 0), ('venus', 0), ('earth', 0), ('mars', 0), }) def test_delete_cte_query(self): raise SkipTest( "this test will not work until `QuerySet.delete` (Django method) " "calls `self.query.chain(sql.DeleteQuery)` instead of " "`sql.DeleteQuery(self.model)`" ) cte = With( Order.objects .values(region_parent=F("region__parent_id")) .annotate(total=Sum("amount")) .filter(total__isnull=False) ) Order.objects.all().with_cte(cte).annotate( cte_has_order=Exists( cte.queryset() .values("total") .filter(region_parent=OuterRef("region_id")) ) ).filter(cte_has_order=False).delete() data = [(o.region_id, o.amount) for o in Order.objects.all()] self.assertEqual(data, [ ('sun', 1000), ('earth', 30), ('earth', 31), ('earth', 32), ('earth', 33), ('proxima centauri', 2000), ]) def test_outerref_in_cte_query(self): # This query is meant to return the difference between min and max # order of each region, through a subquery min_and_max = With( Order.objects .filter(region=OuterRef("pk")) .values('region') # This is to force group by region_id .annotate( amount_min=Min("amount"), amount_max=Max("amount"), ) .values('amount_min', 'amount_max') ) regions = ( Region.objects .annotate( difference=Subquery( min_and_max.queryset().with_cte(min_and_max).annotate( difference=ExpressionWrapper( F('amount_max') - F('amount_min'), output_field=int_field, ), ).values('difference')[:1], output_field=IntegerField() ) ) .order_by("name") ) print(regions.query) data = [(r.name, r.difference) for r in regions] self.assertEqual(data, [ ("bernard's star", None), ('deimos', None), ('earth', 3), ('mars', 2), ('mercury', 2), ('moon', 2), ('phobos', None), ('proxima centauri', 0), ('proxima centauri b', 2), ('sun', 0), ('venus', 3) ]) def test_experimental_left_outer_join(self): totals = With( Order.objects .values("region_id") .annotate(total=Sum("amount")) .filter(total__gt=100) ) orders = ( totals .join(Order, region=totals.col.region_id, _join_type=LOUTER) .with_cte(totals) .annotate(region_total=totals.col.total) ) print(orders.query) self.assertIn("LEFT OUTER JOIN", str(orders.query)) self.assertNotIn("INNER JOIN", str(orders.query)) data = sorted((o.region_id, o.amount, o.region_total) for o in orders) self.assertEqual(data, [ ('earth', 30, 126), ('earth', 31, 126), ('earth', 32, 126), ('earth', 33, 126), ('mars', 40, 123), ('mars', 41, 123), ('mars', 42, 123), ('mercury', 10, None), ('mercury', 11, None), ('mercury', 12, None), ('moon', 1, None), ('moon', 2, None), ('moon', 3, None), ('proxima centauri', 2000, 2000), ('proxima centauri b', 10, None), ('proxima centauri b', 11, None), ('proxima centauri b', 12, None), ('sun', 1000, 1000), ('venus', 20, None), ('venus', 21, None), ('venus', 22, None), ('venus', 23, None), ]) def test_non_cte_subquery(self): """ Verifies that subquery annotations are handled correctly when the subquery model doesn't use the CTE manager, and the query results match expected behavior """ self.assertNotIsInstance(User.objects, CTEManager) sub_totals = With( Order.objects .values(region_parent=F("region__parent_id")) .annotate( total=Sum("amount"), # trivial subquery example testing existence of # a user for the order non_cte_subquery=Exists( User.objects.filter(pk=OuterRef("user_id")) ), ), ) regions = ( Region.objects.all() .with_cte(sub_totals) .annotate( child_regions_total=Subquery( sub_totals.queryset() .filter(region_parent=OuterRef("name")) .values("total"), ), ) .order_by("name") ) print(regions.query) data = [(r.name, r.child_regions_total) for r in regions] self.assertEqual(data, [ ("bernard's star", None), ('deimos', None), ('earth', 6), ('mars', None), ('mercury', None), ('moon', None), ('phobos', None), ('proxima centauri', 33), ('proxima centauri b', None), ('sun', 368), ('venus', None) ]) def test_explain(self): """ Verifies that using .explain() prepends the EXPLAIN clause in the correct position """ totals = With( Order.objects .filter(region__parent="sun") .values("region_id") .annotate(total=Sum("amount")), name="totals", ) region_count = With( Region.objects .filter(parent="sun") .values("parent_id") .annotate(num=Count("name")), name="region_count", ) orders = ( region_count.join( totals.join(Order, region=totals.col.region_id), region__parent=region_count.col.parent_id ) .with_cte(totals) .with_cte(region_count) .annotate(region_total=totals.col.total) .annotate(region_count=region_count.col.num) .order_by("amount") ) print(orders.query) self.assertIsInstance(orders.explain(), str) def test_empty_result_set_cte(self): """ Verifies that the CTEQueryCompiler can handle empty result sets in the related CTEs """ totals = With( Order.objects .filter(id__in=[]) .values("region_id") .annotate(total=Sum("amount")), name="totals", ) orders = ( totals.join(Order, region=totals.col.region_id) .with_cte(totals) .annotate(region_total=totals.col.total) .order_by("amount") ) self.assertEqual(len(orders), 0) def test_left_outer_join_on_empty_result_set_cte(self): totals = With( Order.objects .filter(id__in=[]) .values("region_id") .annotate(total=Sum("amount")), name="totals", ) orders = ( totals.join(Order, region=totals.col.region_id, _join_type=LOUTER) .with_cte(totals) .annotate(region_total=totals.col.total) .order_by("amount") ) self.assertEqual(len(orders), 22) def test_union_query_with_cte(self): orders = ( Order.objects .filter(region__parent="sun") .only("region", "amount") ) orders_cte = With(orders, name="orders_cte") orders_cte_queryset = orders_cte.queryset() earth_orders = orders_cte_queryset.filter(region="earth") mars_orders = orders_cte_queryset.filter(region="mars") earth_mars = earth_orders.union(mars_orders, all=True) earth_mars_cte = ( earth_mars .with_cte(orders_cte) .order_by("region", "amount") .values_list("region", "amount") ) print(earth_mars_cte.query) self.assertEqual(list(earth_mars_cte), [ ('earth', 30), ('earth', 31), ('earth', 32), ('earth', 33), ('mars', 40), ('mars', 41), ('mars', 42), ]) def test_cte_select_pk(self): orders = Order.objects.filter(region="earth").values("pk") cte = With(orders) queryset = cte.join(orders, pk=cte.col.pk).with_cte(cte).order_by("pk") print(queryset.query) self.assertEqual(list(queryset), [ {'pk': 9}, {'pk': 10}, {'pk': 11}, {'pk': 12}, ]) django-cte-2.0.0/tests/test_v1/test_django.py000066400000000000000000000065301502403364700211360ustar00rootroot00000000000000from unittest import SkipTest import django from django.db import OperationalError, ProgrammingError from django.db.models import Window from django.db.models.functions import Rank from django.test import TestCase, skipUnlessDBFeature from .models import Order, Region, User @skipUnlessDBFeature("supports_select_union") class NonCteQueries(TestCase): """Test non-CTE queries These tests were adapted from the Django test suite. The models used here use CTEManager and CTEQuerySet to verify feature parity with their base classes Manager and QuerySet. """ @classmethod def setUpTestData(cls): Order.objects.all().delete() def test_union_with_select_related_and_order(self): e1 = User.objects.create(name="e1") a1 = Order.objects.create(region_id="earth", user=e1) a2 = Order.objects.create(region_id="moon", user=e1) Order.objects.create(region_id="sun", user=e1) base_qs = Order.objects.select_related("user").order_by() qs1 = base_qs.filter(region_id="earth") qs2 = base_qs.filter(region_id="moon") print(qs1.union(qs2).order_by("pk").query) self.assertSequenceEqual(qs1.union(qs2).order_by("pk"), [a1, a2]) @skipUnlessDBFeature("supports_slicing_ordering_in_compound") def test_union_with_select_related_and_first(self): e1 = User.objects.create(name="e1") a1 = Order.objects.create(region_id="earth", user=e1) Order.objects.create(region_id="moon", user=e1) base_qs = Order.objects.select_related("user") qs1 = base_qs.filter(region_id="earth") qs2 = base_qs.filter(region_id="moon") self.assertEqual(qs1.union(qs2).first(), a1) def test_union_with_first(self): e1 = User.objects.create(name="e1") a1 = Order.objects.create(region_id="earth", user=e1) base_qs = Order.objects.order_by() qs1 = base_qs.filter(region_id="earth") qs2 = base_qs.filter(region_id="moon") self.assertEqual(qs1.union(qs2).first(), a1) class WindowFunctions(TestCase): def test_heterogeneous_filter_in_cte(self): if django.VERSION < (4, 2): raise SkipTest("feature added in Django 4.2") from django_cte import With cte = With( Order.objects.annotate( region_amount_rank=Window( Rank(), partition_by="region_id", order_by="-amount" ), ) .order_by("region_id") .values("region_id", "region_amount_rank") .filter(region_amount_rank=1, region_id__in=["sun", "moon"]) ) qs = cte.join(Region, name=cte.col.region_id).with_cte(cte) print(qs.query) # ProgrammingError: column cte.region_id does not exist # WITH RECURSIVE "cte" AS (SELECT * FROM ( # SELECT "orders"."region_id" AS "col1", ... # "region" INNER JOIN "cte" ON "region"."name" = ("cte"."region_id") try: self.assertEqual({r.name for r in qs}, {"moon", "sun"}) except (OperationalError, ProgrammingError) as err: if "cte.region_id" in str(err): raise SkipTest( "window function auto-aliasing breaks CTE " "column references" ) raise if django.VERSION < (5, 2): assert 0, "unexpected pass" django-cte-2.0.0/tests/test_v1/test_manager.py000066400000000000000000000072251502403364700213100ustar00rootroot00000000000000from django.db.models.expressions import F from django.db.models.query import QuerySet from django.test import TestCase from django_cte import With, CTEQuerySet, CTEManager from .models import ( Order, OrderFromLT40, OrderLT40AsManager, OrderCustomManagerNQuery, OrderCustomManager, LT40QuerySet, LTManager, LT25QuerySet, ) class TestCTE(TestCase): def test_cte_queryset_correct_defaultmanager(self): self.assertEqual(type(Order._default_manager), CTEManager) self.assertEqual(type(Order.objects.all()), CTEQuerySet) def test_cte_queryset_correct_from_queryset(self): self.assertEqual(type(OrderFromLT40.objects.all()), LT40QuerySet) def test_cte_queryset_correct_queryset_as_manager(self): self.assertEqual(type(OrderLT40AsManager.objects.all()), LT40QuerySet) def test_cte_queryset_correct_manager_n_from_queryset(self): self.assertIsInstance( OrderCustomManagerNQuery._default_manager, LTManager) self.assertEqual(type( OrderCustomManagerNQuery.objects.all()), LT25QuerySet) def test_cte_create_manager_from_non_cteQuery(self): class BrokenQuerySet(QuerySet): "This should be a CTEQuerySet if we want this to work" with self.assertRaises(TypeError): CTEManager.from_queryset(BrokenQuerySet)() def test_cte_queryset_correct_limitedmanager(self): self.assertEqual(type(OrderCustomManager._default_manager), LTManager) # Check the expected even if not ideal behavior occurs self.assertIsInstance(OrderCustomManager.objects.all(), CTEQuerySet) def test_cte_queryset_with_from_queryset(self): self.assertEqual(type(OrderFromLT40.objects.all()), LT40QuerySet) cte = With( OrderFromLT40.objects .annotate(region_parent=F("region__parent_id")) .filter(region__parent_id="sun") ) orders = ( cte.queryset() .with_cte(cte) .lt40() # custom queryset method .order_by("region_id", "amount") ) print(orders.query) data = [(x.region_id, x.amount, x.region_parent) for x in orders] self.assertEqual(data, [ ("earth", 30, "sun"), ("earth", 31, "sun"), ("earth", 32, "sun"), ("earth", 33, "sun"), ('mercury', 10, 'sun'), ('mercury', 11, 'sun'), ('mercury', 12, 'sun'), ('venus', 20, 'sun'), ('venus', 21, 'sun'), ('venus', 22, 'sun'), ('venus', 23, 'sun'), ]) def test_cte_queryset_with_custom_queryset(self): cte = With( OrderCustomManagerNQuery.objects .annotate(region_parent=F("region__parent_id")) .filter(region__parent_id="sun") ) orders = ( cte.queryset() .with_cte(cte) .lt25() # custom queryset method .order_by("region_id", "amount") ) print(orders.query) data = [(x.region_id, x.amount, x.region_parent) for x in orders] self.assertEqual(data, [ ('mercury', 10, 'sun'), ('mercury', 11, 'sun'), ('mercury', 12, 'sun'), ('venus', 20, 'sun'), ('venus', 21, 'sun'), ('venus', 22, 'sun'), ('venus', 23, 'sun'), ]) def test_cte_queryset_with_deferred_loading(self): cte = With( OrderCustomManagerNQuery.objects.order_by("id").only("id")[:1] ) orders = cte.queryset().with_cte(cte) print(orders.query) self.assertEqual([x.id for x in orders], [1]) django-cte-2.0.0/tests/test_v1/test_raw.py000066400000000000000000000032261502403364700204640ustar00rootroot00000000000000from django.db.models import IntegerField, TextField from django.test import TestCase from django_cte import With from django_cte.raw import raw_cte_sql from .models import Region int_field = IntegerField() text_field = TextField() class TestRawCTE(TestCase): def test_raw_cte_sql(self): cte = With(raw_cte_sql( """ SELECT region_id, AVG(amount) AS avg_order FROM orders WHERE region_id = %s GROUP BY region_id """, ["moon"], {"region_id": text_field, "avg_order": int_field}, )) moon_avg = ( cte .join(Region, name=cte.col.region_id) .annotate(avg_order=cte.col.avg_order) .with_cte(cte) ) print(moon_avg.query) data = [(r.name, r.parent.name, r.avg_order) for r in moon_avg] self.assertEqual(data, [('moon', 'earth', 2)]) def test_raw_cte_sql_name_escape(self): cte = With( raw_cte_sql( """ SELECT region_id, AVG(amount) AS avg_order FROM orders WHERE region_id = %s GROUP BY region_id """, ["moon"], {"region_id": text_field, "avg_order": int_field}, ), name="mixedCaseCTEName" ) moon_avg = ( cte .join(Region, name=cte.col.region_id) .annotate(avg_order=cte.col.avg_order) .with_cte(cte) ) self.assertTrue( str(moon_avg.query).startswith( 'WITH RECURSIVE "mixedCaseCTEName"') ) django-cte-2.0.0/tests/test_v1/test_recursive.py000066400000000000000000000260561502403364700217100ustar00rootroot00000000000000import pickle from unittest import SkipTest from django.db.models import IntegerField, TextField from django.db.models.expressions import ( Case, Exists, ExpressionWrapper, F, OuterRef, Q, Value, When, ) from django.db.models.functions import Concat from django.db.utils import DatabaseError from django.test import TestCase from django_cte import With from .models import KeyPair, Region int_field = IntegerField() text_field = TextField() class TestRecursiveCTE(TestCase): def test_recursive_cte_query(self): def make_regions_cte(cte): return Region.objects.filter( # non-recursive: get root nodes parent__isnull=True ).values( "name", path=F("name"), depth=Value(0, output_field=int_field), ).union( # recursive union: get descendants cte.join(Region, parent=cte.col.name).values( "name", path=Concat( cte.col.path, Value(" / "), F("name"), output_field=text_field, ), depth=cte.col.depth + Value(1, output_field=int_field), ), all=True, ) cte = With.recursive(make_regions_cte) regions = ( cte.join(Region, name=cte.col.name) .with_cte(cte) .annotate( path=cte.col.path, depth=cte.col.depth, ) .filter(depth=2) .order_by("path") ) print(regions.query) data = [(r.name, r.path, r.depth) for r in regions] self.assertEqual(data, [ ('moon', 'sun / earth / moon', 2), ('deimos', 'sun / mars / deimos', 2), ('phobos', 'sun / mars / phobos', 2), ]) def test_recursive_cte_reference_in_condition(self): def make_regions_cte(cte): return Region.objects.filter( parent__isnull=True ).values( "name", path=F("name"), depth=Value(0, output_field=int_field), is_planet=Value(0, output_field=int_field), ).union( cte.join( Region, parent=cte.col.name ).annotate( # annotations for filter and CASE/WHEN conditions parent_name=ExpressionWrapper( cte.col.name, output_field=text_field, ), parent_depth=ExpressionWrapper( cte.col.depth, output_field=int_field, ), ).filter( ~Q(parent_name="mars"), ).values( "name", path=Concat( cte.col.path, Value("\x01"), F("name"), output_field=text_field, ), depth=cte.col.depth + Value(1, output_field=int_field), is_planet=Case( When(parent_depth=0, then=Value(1)), default=Value(0), output_field=int_field, ), ), all=True, ) cte = With.recursive(make_regions_cte) regions = cte.join(Region, name=cte.col.name).with_cte(cte).annotate( path=cte.col.path, depth=cte.col.depth, is_planet=cte.col.is_planet, ).order_by("path") data = [(r.path.split("\x01"), r.is_planet) for r in regions] print(data) self.assertEqual(data, [ (["bernard's star"], 0), (['proxima centauri'], 0), (['proxima centauri', 'proxima centauri b'], 1), (['sun'], 0), (['sun', 'earth'], 1), (['sun', 'earth', 'moon'], 0), (['sun', 'mars'], 1), # mars moons excluded: parent_name != 'mars' (['sun', 'mercury'], 1), (['sun', 'venus'], 1), ]) def test_recursive_cte_with_empty_union_part(self): def make_regions_cte(cte): return Region.objects.none().union( cte.join(Region, parent=cte.col.name), all=True, ) cte = With.recursive(make_regions_cte) regions = cte.join(Region, name=cte.col.name).with_cte(cte) print(regions.query) try: self.assertEqual(regions.count(), 0) except DatabaseError: raise SkipTest( "Expected failure: QuerySet omits `EmptyQuerySet` from " "UNION queries resulting in invalid CTE SQL" ) # -- recursive query "cte" does not have the form # -- non-recursive-term UNION [ALL] recursive-term # WITH RECURSIVE cte AS ( # SELECT "tests_region"."name", "tests_region"."parent_id" # FROM "tests_region", "cte" # WHERE "tests_region"."parent_id" = ("cte"."name") # ) # SELECT COUNT(*) # FROM "tests_region", "cte" # WHERE "tests_region"."name" = ("cte"."name") def test_circular_ref_error(self): def make_bad_cte(cte): # NOTE: not a valid recursive CTE query return cte.join(Region, parent=cte.col.name).values( depth=cte.col.depth + 1, ) cte = With.recursive(make_bad_cte) regions = cte.join(Region, name=cte.col.name).with_cte(cte) with self.assertRaises(ValueError) as context: print(regions.query) self.assertIn("Circular reference:", str(context.exception)) def test_attname_should_not_mask_col_name(self): def make_regions_cte(cte): return Region.objects.filter( name="moon" ).values( "name", "parent_id", ).union( cte.join(Region, name=cte.col.parent_id).values( "name", "parent_id", ), all=True, ) cte = With.recursive(make_regions_cte) regions = ( Region.objects.all() .with_cte(cte) .annotate(_ex=Exists( cte.queryset() .values(value=Value("1", output_field=int_field)) .filter(name=OuterRef("name")) )) .filter(_ex=True) .order_by("name") ) print(regions.query) data = [r.name for r in regions] self.assertEqual(data, ['earth', 'moon', 'sun']) def test_pickle_recursive_cte_queryset(self): def make_regions_cte(cte): return Region.objects.filter( parent__isnull=True ).annotate( depth=Value(0, output_field=int_field), ).union( cte.join(Region, parent=cte.col.name).annotate( depth=cte.col.depth + Value(1, output_field=int_field), ), all=True, ) cte = With.recursive(make_regions_cte) regions = cte.queryset().with_cte(cte).filter(depth=2).order_by("name") pickled_qs = pickle.loads(pickle.dumps(regions)) data = [(r.name, r.depth) for r in pickled_qs] self.assertEqual(data, [(r.name, r.depth) for r in regions]) self.assertEqual(data, [('deimos', 2), ('moon', 2), ('phobos', 2)]) def test_alias_change_in_annotation(self): def make_regions_cte(cte): return Region.objects.filter( parent__name="sun", ).annotate( value=F('name'), ).union( cte.join( Region.objects.all().annotate( value=F('name'), ), parent_id=cte.col.name, ), all=True, ) cte = With.recursive(make_regions_cte) query = cte.queryset().with_cte(cte) exclude_leaves = With(cte.queryset().filter( parent__name='sun', ).annotate( value=Concat(F('name'), F('name')) ), name='value_cte') query = query.annotate( _exclude_leaves=Exists( exclude_leaves.queryset().filter( name=OuterRef("name"), value=OuterRef("value"), ) ) ).filter(_exclude_leaves=True).with_cte(exclude_leaves) print(query.query) # Nothing should be returned. self.assertFalse(query) def test_alias_as_subquery(self): # This test covers CTEColumnRef.relabeled_clone def make_regions_cte(cte): return KeyPair.objects.filter( parent__key="level 1", ).annotate( rank=F('value'), ).union( cte.join( KeyPair.objects.all().order_by(), parent_id=cte.col.id, ).annotate( rank=F('value'), ), all=True, ) cte = With.recursive(make_regions_cte) children = cte.queryset().with_cte(cte) xdups = With(cte.queryset().filter( parent__key="level 1", ).annotate( rank=F('value') ).values('id', 'rank'), name='xdups') children = children.annotate( _exclude=Exists( ( xdups.queryset().filter( id=OuterRef("id"), rank=OuterRef("rank"), ) ) ) ).filter(_exclude=True).with_cte(xdups) print(children.query) query = KeyPair.objects.filter(parent__in=children) print(query.query) print(children.query) self.assertEqual(query.get().key, 'level 3') # Tests the case in which children's query was modified since it was # used in a subquery to define `query` above. self.assertEqual( list(c.key for c in children), ['level 2', 'level 2'] ) def test_materialized(self): # This test covers MATERIALIZED option in SQL query def make_regions_cte(cte): return KeyPair.objects.all() cte = With.recursive(make_regions_cte, materialized=True) query = KeyPair.objects.with_cte(cte) print(query.query) self.assertTrue( str(query.query).startswith('WITH RECURSIVE "cte" AS MATERIALIZED') ) def test_recursive_self_queryset(self): def make_regions_cte(cte): return Region.objects.filter( pk="earth" ).values("pk").union( cte.join(Region, parent=cte.col.pk).values("pk") ) cte = With.recursive(make_regions_cte) queryset = cte.queryset().with_cte(cte).order_by("pk") print(queryset.query) self.assertEqual(list(queryset), [ {'pk': 'earth'}, {'pk': 'moon'}, ]) django-cte-2.0.0/uv.lock000066400000000000000000001301431502403364700150360ustar00rootroot00000000000000version = 1 revision = 2 requires-python = ">=3.9" resolution-markers = [ "python_full_version >= '3.10'", "python_full_version < '3.10'", ] [[package]] name = "asgiref" version = "3.8.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] 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" } wheels = [ { 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" }, ] [[package]] name = "colorama" version = "0.4.6" source = { registry = "https://pypi.org/simple" } 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" } wheels = [ { 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" }, ] [[package]] name = "django" version = "4.2.21" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asgiref" }, { name = "sqlparse" }, { name = "tzdata", marker = "sys_platform == 'win32'" }, ] 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" } wheels = [ { 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" }, ] [[package]] name = "django-cte" source = { editable = "." } dependencies = [ { name = "django" }, ] [package.dev-dependencies] dev = [ { name = "psycopg2-binary" }, { name = "pytest-unmagic" }, { name = "ruff" }, ] [package.metadata] requires-dist = [{ name = "django" }] [package.metadata.requires-dev] dev = [ { name = "psycopg2-binary" }, { name = "pytest-unmagic" }, { name = "ruff" }, ] [[package]] name = "exceptiongroup" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] 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" } wheels = [ { 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" }, ] [[package]] name = "iniconfig" version = "2.1.0" source = { registry = "https://pypi.org/simple" } 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" } wheels = [ { 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" }, ] [[package]] name = "packaging" version = "25.0" source = { registry = "https://pypi.org/simple" } 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" } wheels = [ { 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" }, ] [[package]] name = "pluggy" version = "1.6.0" source = { registry = "https://pypi.org/simple" } 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" } wheels = [ { 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" }, ] [[package]] name = "psycopg2-binary" version = "2.9.10" source = { registry = "https://pypi.org/simple" } 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" } wheels = [ { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, ] [[package]] name = "pytest" version = "8.3.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] 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" } wheels = [ { 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" }, ] [[package]] name = "pytest-unmagic" version = "1.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] 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" } wheels = [ { 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" }, ] [[package]] name = "ruff" version = "0.11.11" source = { registry = "https://pypi.org/simple" } 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" } wheels = [ { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, ] [[package]] name = "sqlparse" version = "0.5.3" source = { registry = "https://pypi.org/simple" } 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" } wheels = [ { 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" }, ] [[package]] name = "tomli" version = "2.2.1" source = { registry = "https://pypi.org/simple" } 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" } wheels = [ { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, ] [[package]] name = "typing-extensions" version = "4.13.2" source = { registry = "https://pypi.org/simple" } 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" } wheels = [ { 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" }, ] [[package]] name = "tzdata" version = "2025.2" source = { registry = "https://pypi.org/simple" } 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" } wheels = [ { 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" }, ]