pax_global_header00006660000000000000000000000064146124634420014520gustar00rootroot0000000000000052 comment=ec23dc1d527f9ecf78ed8f141bf7f02cf21da3e8 django-tree-queries-0.19/000077500000000000000000000000001461246344200153235ustar00rootroot00000000000000django-tree-queries-0.19/.editorconfig000066400000000000000000000003101461246344200177720ustar00rootroot00000000000000# top-most EditorConfig file root = true [*] end_of_line = lf insert_final_newline = true charset = utf-8 trim_trailing_whitespace = true indent_style = space indent_size = 2 [*.py] indent_size = 4 django-tree-queries-0.19/.github/000077500000000000000000000000001461246344200166635ustar00rootroot00000000000000django-tree-queries-0.19/.github/FUNDING.yml000066400000000000000000000012521461246344200205000ustar00rootroot00000000000000# These are supported funding model platforms github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] patreon: # Replace with a single Patreon username open_collective: # Replace with a single Open Collective username ko_fi: matthiask tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry liberapay: # Replace with a single Liberapay username issuehunt: # Replace with a single IssueHunt username otechie: # Replace with a single Otechie username custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] django-tree-queries-0.19/.github/workflows/000077500000000000000000000000001461246344200207205ustar00rootroot00000000000000django-tree-queries-0.19/.github/workflows/test.yml000066400000000000000000000100351461246344200224210ustar00rootroot00000000000000name: Test on: push: pull_request: schedule: - cron: "37 1 1 * *" jobs: mysql: runs-on: ubuntu-latest strategy: fail-fast: false max-parallel: 5 matrix: python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] services: mariadb: image: mariadb env: MARIADB_ROOT_PASSWORD: tree_queries options: >- --health-cmd "mariadb-admin ping" --health-interval 10s --health-timeout 5s --health-retries 5 ports: - 3306:3306 steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} - name: Get pip cache dir id: pip-cache run: | echo "::set-output name=dir::$(pip cache dir)" - name: Cache uses: actions/cache@v2 with: path: ${{ steps.pip-cache.outputs.dir }} key: ${{ matrix.python-version }}-v1-${{ hashFiles('**/setup.cfg') }}-${{ hashFiles('**/tox.ini') }} restore-keys: | ${{ matrix.python-version }}-v1- - name: Install dependencies run: | python -m pip install --upgrade pip python -m pip install --upgrade tox tox-gh-actions - name: Test with tox run: tox env: DB_BACKEND: mysql DB_USER: root DB_PASSWORD: tree_queries DB_HOST: 127.0.0.1 DB_PORT: 3306 postgres: runs-on: ubuntu-latest strategy: fail-fast: false max-parallel: 5 matrix: python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] services: postgres: image: postgres env: POSTGRES_DB: tree_queries POSTGRES_USER: tree_queries POSTGRES_PASSWORD: tree_queries ports: - 5432:5432 options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} - name: Get pip cache dir id: pip-cache run: | echo "::set-output name=dir::$(pip cache dir)" - name: Cache uses: actions/cache@v2 with: path: ${{ steps.pip-cache.outputs.dir }} key: ${{ matrix.python-version }}-v1-${{ hashFiles('**/setup.cfg') }}-${{ hashFiles('**/tox.ini') }} restore-keys: | ${{ matrix.python-version }}-v1- - name: Install dependencies run: | python -m pip install --upgrade pip python -m pip install --upgrade tox tox-gh-actions - name: Test with tox run: tox env: DB_BACKEND: postgresql DB_HOST: localhost DB_PORT: 5432 sqlite: runs-on: ubuntu-latest strategy: fail-fast: false max-parallel: 5 matrix: python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} - name: Get pip cache dir id: pip-cache run: | echo "::set-output name=dir::$(pip cache dir)" - name: Cache uses: actions/cache@v2 with: path: ${{ steps.pip-cache.outputs.dir }} key: ${{ matrix.python-version }}-v1-${{ hashFiles('**/setup.cfg') }}-${{ hashFiles('**/tox.ini') }} restore-keys: | ${{ matrix.python-version }}-v1- - name: Install dependencies run: | python -m pip install --upgrade pip python -m pip install --upgrade tox tox-gh-actions - name: Test with tox run: tox env: DB_BACKEND: sqlite3 DB_NAME: ":memory:" django-tree-queries-0.19/.gitignore000066400000000000000000000002411461246344200173100ustar00rootroot00000000000000*.py? *~ *.sw? \#*# /secrets.py .DS_Store ._* *.egg-info /MANIFEST /_build /build dist tests/test.zip /docs/_build /.eggs .coverage htmlcov venv .tox docs/build django-tree-queries-0.19/.pre-commit-config.yaml000066400000000000000000000023421461246344200216050ustar00rootroot00000000000000exclude: ".yarn/|yarn.lock|\\.min\\.(css|js)$" repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.6.0 hooks: - id: check-added-large-files - id: check-builtin-literals - id: check-executables-have-shebangs - id: check-merge-conflict - id: check-toml - id: check-yaml - id: detect-private-key - id: end-of-file-fixer - id: mixed-line-ending - id: trailing-whitespace - repo: https://github.com/adamchainz/django-upgrade rev: 1.16.0 hooks: - id: django-upgrade args: [--target-version, "3.2"] - repo: https://github.com/MarcoGorelli/absolufy-imports rev: v0.3.1 hooks: - id: absolufy-imports - repo: https://github.com/astral-sh/ruff-pre-commit rev: "v0.4.1" hooks: - id: ruff - id: ruff-format - repo: https://github.com/pre-commit/mirrors-prettier rev: v3.1.0 hooks: - id: prettier args: [--list-different, --no-semi] exclude: "^conf/|.*\\.html$" - repo: https://github.com/tox-dev/pyproject-fmt rev: 1.8.0 hooks: - id: pyproject-fmt - repo: https://github.com/abravalheri/validate-pyproject rev: v0.16 hooks: - id: validate-pyproject django-tree-queries-0.19/CHANGELOG.rst000066400000000000000000000154751461246344200173600ustar00rootroot00000000000000Change log ========== Next version ~~~~~~~~~~~~ 0.19 (2024-04-25) ~~~~~~~~~~~~~~~~~ - Reimplemented the rank table construction using a real queryset; this enables support for pre-filtering the tree queryset using ``.tree_filter()`` and ``.tree_exclude()``. Thanks rhomboss! - Added a ``.tree_fields()`` method to allow adding additional columns to the tree queryset, allowing collecting ancestors fields directly when running the initial query. For example, ``.tree_fields(tree_names="name")`` will collect all ``name`` fields in a ``tree_fields`` array on the model instances. For now the code only supports string fields and integer fields. 0.18 (2024-04-03) ~~~~~~~~~~~~~~~~~ - Fixed broken SQL which was generated when using a tree query with ``EXISTS()`` subqueries. 0.17 (2024-03-26) ~~~~~~~~~~~~~~~~~ - Preserved the tree ordering even when using ``.values()`` or ``.values_list()``. Thanks Glenn Matthews! - Added support for descending sibling ordering, multi-field sibling ordering, and related field sibling ordering. Thanks rhomboss! 0.16 (2023-11-29) ~~~~~~~~~~~~~~~~~ - Added Python 3.12, Django 5.0. - Fixed a problem where ``.values()`` would return an incorrect mapping. Thanks Glenn Matthews! - Started running tests periodically to catch bugs earlier. 0.15 (2023-06-19) ~~~~~~~~~~~~~~~~~ - Switched to ruff and hatchling. - Dropped Django 4.0. - Added Python 3.11. - Added a ``.without_tree_fields()`` method which calls ``.with_tree_fields(False)`` in a way which doesn't trigger the flake8 boolean trap linter. `0.14`_ (2023-01-30) ~~~~~~~~~~~~~~~~~~~~ .. _0.14: https://github.com/matthiask/django-tree-queries/compare/0.13...0.14 - Changed the behavior around sibling ordering to warn if using ``Meta.ordering`` where ordering contains more than one field. - Added Django 4.2a1 to the CI. - Django 5.0 will require Python 3.10 or better, pruned the CI jobs list. - Added quoting to the field name for the ordering between siblings so that fields named ``order`` can be used. Thanks Tao Bojlén! - Narrowed exception catching when determining whether the ordering field is an integer field or not. Thanks Tao Bojlén. `0.13`_ (2022-12-08) ~~~~~~~~~~~~~~~~~~~~ .. _0.13: https://github.com/matthiask/django-tree-queries/compare/0.12...0.13 - Made it possible to use tree queries with multiple table inheritance. Thanks Olivier Dalang for the testcases and the initial implementation! `0.12`_ (2022-11-30) ~~~~~~~~~~~~~~~~~~~~ .. _0.12: https://github.com/matthiask/django-tree-queries/compare/0.11...0.12 - Removed compatibility with Django < 3.2, Python < 3.8. - Added Django 4.1 to the CI. - Fixed ``.with_tree_fields().explain()`` on some databases. Thanks Bryan Culver! `0.11`_ (2022-06-10) ~~~~~~~~~~~~~~~~~~~~ .. _0.11: https://github.com/matthiask/django-tree-queries/compare/0.10...0.11 - Fixed a crash when running ``.with_tree_fields().distinct().count()`` by 1. avoiding to select tree fields in distinct subqueries and 2. trusting the testsuite. `0.10`_ (2022-06-07) ~~~~~~~~~~~~~~~~~~~~ .. _0.10: https://github.com/matthiask/django-tree-queries/compare/0.9...0.10 - Fixed ordering by string fields to actually work correctly in the presence of values of varying length. `0.9`_ (2022-04-01) ~~~~~~~~~~~~~~~~~~~ .. _0.9: https://github.com/matthiask/django-tree-queries/compare/0.8...0.9 - Added ``TreeQuerySet.order_siblings_by`` which allows specifying an ordering for siblings per-query. `0.8`_ (2022-03-09) ~~~~~~~~~~~~~~~~~~~ .. _0.8: https://github.com/matthiask/django-tree-queries/compare/0.7...0.8 - Added pre-commit configuration to automatically remove some old-ish code patterns. - Fixed a compatibility problem with the upcoming Django 4.1. `0.7`_ (2021-10-31) ~~~~~~~~~~~~~~~~~~~ .. _0.7: https://github.com/matthiask/django-tree-queries/compare/0.6...0.7 - Added a test with a tree node having a UUID as its primary key. `0.6`_ (2021-07-21) ~~~~~~~~~~~~~~~~~~~ - Fixed ``TreeQuerySet.ancestors`` to support primary keys not named ``id``. - Changed the tree compiler to only post-process its own database results. - Added ``**kwargs``-passing to ``TreeQuery.get_compiler`` for compatibility with Django 4.0. `0.5`_ (2021-05-12) ~~~~~~~~~~~~~~~~~~~ - Added support for adding tree fields to queries by default. Create a manager using ``TreeQuerySet.as_manager(with_tree_fields=True)``. - Ensured the availability of the ``with_tree_fields`` configuration also on subclassed managers, e.g. those used for traversing reverse relations. - Dropped compatibility with Django 1.8 to avoid adding workarounds to the testsuite. - Made it possible to use django-tree-queries in more situations involving JOINs. Thanks Safa Alfulaij for the contribution! `0.4`_ (2020-09-13) ~~~~~~~~~~~~~~~~~~~ - Fixed a grave bug where a position of ``110`` would be sorted before ``20`` for obvious reasons. - Added a custom ``TreeNodeForeignKey.deconstruct`` method to avoid migrations because of changing field types. - Removed one case of unnecessary fumbling in ``Query``'s internals making things needlessly harder than they need to be. Made django-tree-queries compatible with Django's master branch. - Removed Python 3.4 from the Travis CI job list. - Dropped the conversion of primary keys to text on PostgreSQL. It's a documented constraint that django-tree-queries only supports integer primary keys, therefore the conversion wasn't necessary at all. - Reverted to using integer arrays on PostgreSQL for ordering if possible instead of always converting everything to padded strings. `0.3`_ (2018-11-15) ~~~~~~~~~~~~~~~~~~~ - Added a ``label_from_instance`` override to the form fields. - Removed the limitation that nodes can only be ordered using an integer field within their siblings. - Changed the representation of ``tree_path`` and ``tree_ordering`` used on MySQL/MariaDB and sqlite3. Also made it clear that the representation isn't part of the public interface of this package. `0.2`_ (2018-10-04) ~~~~~~~~~~~~~~~~~~~ - Added an optional argument to ``TreeQuerySet.with_tree_fields()`` to allow reverting to a standard queryset (without tree fields). - Added ``tree_queries.fields.TreeNodeForeignKey``, ``tree_queries.forms.TreeNodeChoiceField`` and ``tree_queries.forms.TreeNodeMultipleChoiceField`` with node depth visualization. - Dropped Python 3.4 from the CI. `0.1`_ (2018-07-30) ~~~~~~~~~~~~~~~~~~~ - Initial release! .. _0.1: https://github.com/matthiask/django-tree-queries/commit/93d70046a2 .. _0.2: https://github.com/matthiask/django-tree-queries/compare/0.1...0.2 .. _0.3: https://github.com/matthiask/django-tree-queries/compare/0.2...0.3 .. _0.4: https://github.com/matthiask/django-tree-queries/compare/0.3...0.4 .. _0.5: https://github.com/matthiask/django-tree-queries/compare/0.4...0.5 .. _0.6: https://github.com/matthiask/django-tree-queries/compare/0.5...0.6 django-tree-queries-0.19/LICENSE000066400000000000000000000030141461246344200163260ustar00rootroot00000000000000Copyright (c) 2018, Feinheit AG 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: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. 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. 3. Neither the name of Feinheit AG 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 THE COPYRIGHT OWNER OR CONTRIBUTORS 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-tree-queries-0.19/README.rst000066400000000000000000000156411461246344200170210ustar00rootroot00000000000000=================== django-tree-queries =================== .. image:: https://github.com/matthiask/django-tree-queries/actions/workflows/test.yml/badge.svg :target: https://github.com/matthiask/django-tree-queries/ :alt: CI Status Query Django model trees using adjacency lists and recursive common table expressions. Supports PostgreSQL, sqlite3 (3.8.3 or higher) and MariaDB (10.2.2 or higher) and MySQL (8.0 or higher, if running without ``ONLY_FULL_GROUP_BY``). Supports Django 3.2 or better, Python 3.8 or better. See the GitHub actions build for more details. Features and limitations ======================== - Supports only integer and UUID primary keys (for now). - Allows specifying ordering among siblings. - Uses the correct definition of depth, where root nodes have a depth of zero. - The parent foreign key must be named ``"parent"`` at the moment (but why would you want to name it differently?) - The fields added by the common table expression always are ``tree_depth``, ``tree_path`` and ``tree_ordering``. The names cannot be changed. ``tree_depth`` is an integer, ``tree_path`` an array of primary keys and ``tree_ordering`` an array of values used for ordering nodes within their siblings. Note that the contents of the ``tree_path`` and ``tree_ordering`` are subject to change. You shouldn't rely on their contents. - Besides adding the fields mentioned above the package only adds queryset methods for ordering siblings and filtering ancestors and descendants. Other features may be useful, but will not be added to the package just because it's possible to do so. - Little code, and relatively simple when compared to other tree management solutions for Django. No redundant values so the only way to end up with corrupt data is by introducing a loop in the tree structure (making it a graph). The ``TreeNode`` abstract model class has some protection against this. - Supports only trees with max. 50 levels on MySQL/MariaDB, since those databases do not support arrays and require us to provide a maximum length for the ``tree_path`` and ``tree_ordering`` upfront. Here's a blog post offering some additional insight (hopefully) into the reasons for `django-tree-queries' existence `_. Usage ===== - Install ``django-tree-queries`` using pip. - Extend ``tree_queries.models.TreeNode`` or build your own queryset and/or manager using ``tree_queries.query.TreeQuerySet``. The ``TreeNode`` abstract model already contains a ``parent`` foreign key for your convenience and also uses model validation to protect against loops. - Call the ``with_tree_fields()`` queryset method if you require the additional fields respectively the CTE. - Call the ``order_siblings_by("field_name")`` queryset method if you want to order tree siblings by a specific model field. Note that Django's standard ``order_by()`` method isn't supported -- nodes are returned according to the `depth-first search algorithm `__. - Create a manager using ``TreeQuerySet.as_manager(with_tree_fields=True)`` if you want to add tree fields to queries by default. - Until documentation is more complete I'll have to refer you to the `test suite `_ for additional instructions and usage examples, or check the recipes below. Recipes ======= Basic models ~~~~~~~~~~~~ The following two examples both extend the ``TreeNode`` which offers a few agreeable utilities and a model validation method that prevents loops in the tree structure. The common table expression could be hardened against such loops but this would involve a performance hit which we don't want -- this is a documented limitation (non-goal) of the library after all. Basic tree node --------------- .. code-block:: python from tree_queries.models import TreeNode class Node(TreeNode): name = models.CharField(max_length=100) Tree node with ordering among siblings -------------------------------------- Nodes with the same parent may be ordered among themselves. The default is to order siblings by their primary key but that's not always very useful. .. code-block:: python from tree_queries.models import TreeNode class Node(TreeNode): name = models.CharField(max_length=100) position = models.PositiveIntegerField(default=0) class Meta: ordering = ["position"] Add custom methods to queryset ------------------------------ .. code-block:: python from tree_queries.models import TreeNode from tree_queries.query import TreeQuerySet class NodeQuerySet(TreeQuerySet): def active(self): return self.filter(is_active=True) class Node(TreeNode): is_active = models.BooleanField(default=True) objects = NodeQuerySet.as_manager() Querying the tree ~~~~~~~~~~~~~~~~~ All examples assume the ``Node`` class from above. Basic usage ----------- .. code-block:: python # Basic usage, disregards the tree structure completely. nodes = Node.objects.all() # Fetch nodes in depth-first search order. All nodes will have the # tree_path, tree_ordering and tree_depth attributes. nodes = Node.objects.with_tree_fields() # Fetch any node. node = Node.objects.order_by("?").first() # Fetch direct children and include tree fields. (The parent ForeignKey # specifies related_name="children") children = node.children.with_tree_fields() # Fetch all ancestors starting from the root. ancestors = node.ancestors() # Fetch all ancestors including self, starting from the root. ancestors_including_self = node.ancestors(include_self=True) # Fetch all ancestors starting with the node itself. ancestry = node.ancestors(include_self=True).reverse() # Fetch all descendants in depth-first search order, including self. descendants = node.descendants(include_self=True) # Temporarily override the ordering by siblings. nodes = Node.objects.order_siblings_by("id") Breadth-first search -------------------- Nobody wants breadth-first search but if you still want it you can achieve it as follows: .. code-block:: python nodes = Node.objects.with_tree_fields().extra( order_by=["__tree.tree_depth", "__tree.tree_ordering"] ) Filter by depth --------------- If you only want nodes from the top two levels: .. code-block:: python nodes = Node.objects.with_tree_fields().extra( where=["__tree.tree_depth <= %s"], params=[1], ) Form fields ~~~~~~~~~~~ django-tree-queries ships a model field and some form fields which augment the default foreign key field and the choice fields with a version where the tree structure is visualized using dashes etc. Those fields are ``tree_queries.fields.TreeNodeForeignKey``, ``tree_queries.forms.TreeNodeChoiceField``, ``tree_queries.forms.TreeNodeMultipleChoiceField``. django-tree-queries-0.19/docs/000077500000000000000000000000001461246344200162535ustar00rootroot00000000000000django-tree-queries-0.19/docs/Makefile000066400000000000000000000011341461246344200177120ustar00rootroot00000000000000# Minimal makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = python -msphinx SPHINXPROJ = test SOURCEDIR = . BUILDDIR = build # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) .PHONY: help Makefile # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) django-tree-queries-0.19/docs/conf.py000066400000000000000000000024651461246344200175610ustar00rootroot00000000000000import os import re import subprocess import sys from datetime import date sys.path.append(os.path.abspath("..")) project = "django-tree-queries" author = "Feinheit AG" copyright = f"2018-{date.today().year}, {author}" # noqa: A001 version = __import__("tree_queries").__version__ release = subprocess.check_output( "git fetch --tags; git describe", shell=True, text=True ).strip() language = "en" ####################################### project_slug = re.sub(r"[^a-z]+", "", project) extensions = [] templates_path = ["_templates"] source_suffix = ".rst" master_doc = "index" exclude_patterns = ["build", "Thumbs.db", ".DS_Store"] pygments_style = "sphinx" todo_include_todos = False html_theme = "alabaster" html_static_path = ["_static"] htmlhelp_basename = project_slug + "doc" latex_elements = { "papersize": "a4", } latex_documents = [ ( master_doc, project_slug + ".tex", project + " Documentation", author, "manual", ) ] man_pages = [ ( master_doc, project_slug, project + " Documentation", [author], 1, ) ] texinfo_documents = [ ( master_doc, project_slug, project + " Documentation", author, project_slug, "", # Description "Miscellaneous", ) ] django-tree-queries-0.19/docs/index.rst000066400000000000000000000000711461246344200201120ustar00rootroot00000000000000.. include:: ../README.rst .. include:: ../CHANGELOG.rst django-tree-queries-0.19/docs/make.bat000066400000000000000000000013751461246344200176660ustar00rootroot00000000000000@ECHO OFF pushd %~dp0 REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=python -msphinx ) set SOURCEDIR=. set BUILDDIR=build set SPHINXPROJ=test if "%1" == "" goto help %SPHINXBUILD% >NUL 2>NUL if errorlevel 9009 ( echo. echo.The Sphinx module was not found. Make sure you have Sphinx installed, echo.then set the SPHINXBUILD environment variable to point to the full echo.path of the 'sphinx-build' executable. Alternatively you may add the echo.Sphinx directory to PATH. echo. echo.If you don't have Sphinx installed, grab it from echo.http://sphinx-doc.org/ exit /b 1 ) %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% goto end :help %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% :end popd django-tree-queries-0.19/pyproject.toml000066400000000000000000000043011461246344200202350ustar00rootroot00000000000000[build-system] build-backend = "hatchling.build" requires = [ "hatchling", ] [project] name = "django-tree-queries" description = "Tree queries with explicit opt-in, without configurability" readme = "README.rst" license = {text = "BSD-3-Clause"} authors = [ { name = "Matthias Kestenholz", email = "mk@feinheit.ch" }, ] requires-python = ">=3.8" classifiers = [ "Environment :: Web Environment", "Framework :: Django", "Intended Audience :: Developers", "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Topic :: Internet :: WWW/HTTP :: Dynamic Content", "Topic :: Software Development", ] dynamic = [ "version", ] [project.optional-dependencies] tests = [ "coverage", ] [project.urls] Homepage = "https://github.com/matthiask/django-tree-queries/" [tool.hatch.build] include = ["tree_queries/"] [tool.hatch.version] path = "tree_queries/__init__.py" [tool.ruff] fix = true preview = true show-fixes = true target-version = "py38" [tool.ruff.lint] extend-select = [ # pyflakes, pycodestyle "F", "E", "W", # mmcabe "C90", # isort "I", # pep8-naming "N", # pyupgrade "UP", # flake8-2020 "YTT", # flake8-boolean-trap "FBT", # flake8-bugbear "B", # flake8-builtins "A", # flake8-comprehensions "C4", # flake8-django "DJ", # flake8-logging-format "G", # flake8-pie "PIE", # flake8-simplify "SIM", # flake8-gettext "INT", # pygrep-hooks "PGH", # pylint "PLC", "PLE", "PLW", # unused noqa "RUF100", ] extend-ignore = [ # Allow zip() without strict= "B905", # No line length errors "E501", ] [tool.ruff.lint.isort] combine-as-imports = true lines-after-imports = 2 [tool.ruff.lint.mccabe] max-complexity = 15 [tool.ruff.lint.per-file-ignores] "*/migrat*/*" = [ # Allow using PascalCase model names in migrations "N806", # Ignore the fact that migration files are invalid module names "N999", ] django-tree-queries-0.19/tests/000077500000000000000000000000001461246344200164655ustar00rootroot00000000000000django-tree-queries-0.19/tests/.gitignore000066400000000000000000000000241461246344200204510ustar00rootroot00000000000000/.coverage /htmlcov django-tree-queries-0.19/tests/manage.py000077500000000000000000000005351461246344200202750ustar00rootroot00000000000000#!/usr/bin/env python import os import sys from os.path import abspath, dirname if __name__ == "__main__": os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testapp.settings") sys.path.insert(0, dirname(dirname(abspath(__file__)))) from django.core.management import execute_from_command_line execute_from_command_line(sys.argv) django-tree-queries-0.19/tests/testapp/000077500000000000000000000000001461246344200201455ustar00rootroot00000000000000django-tree-queries-0.19/tests/testapp/__init__.py000066400000000000000000000000001461246344200222440ustar00rootroot00000000000000django-tree-queries-0.19/tests/testapp/admin.py000066400000000000000000000002361461246344200216100ustar00rootroot00000000000000from django.contrib import admin from testapp import models @admin.register(models.Model) class ModelAdmin(admin.ModelAdmin): list_display = ("name",) django-tree-queries-0.19/tests/testapp/models.py000066400000000000000000000057071461246344200220130ustar00rootroot00000000000000import uuid from django.db import models from tree_queries.models import TreeNode from tree_queries.query import TreeQuerySet class Model(TreeNode): custom_id = models.AutoField(primary_key=True) order = models.PositiveIntegerField(default=0) name = models.CharField(max_length=100) class Meta: ordering = ("order",) def __str__(self): return self.name class UnorderedModel(TreeNode): pass class StringOrderedModel(TreeNode): name = models.CharField(max_length=100) class Meta: ordering = ("name",) unique_together = (("name", "parent"),) def __str__(self): return self.name class AlwaysTreeQueryModelCategory(models.Model): def __str__(self): return "" class ReferenceModel(models.Model): position = models.PositiveIntegerField(default=0) tree_field = models.ForeignKey( Model, on_delete=models.CASCADE, blank=True, null=True, ) class Meta: ordering = ("position",) def __str__(self): return "" class AlwaysTreeQueryModel(TreeNode): name = models.CharField(max_length=100) related = models.ManyToManyField("self", symmetrical=True) category = models.ForeignKey( AlwaysTreeQueryModelCategory, on_delete=models.CASCADE, blank=True, null=True, related_name="instances", ) objects = TreeQuerySet.as_manager(with_tree_fields=True) class Meta: base_manager_name = "objects" def __str__(self): return self.name class UUIDModel(TreeNode): id = models.UUIDField(primary_key=True, default=uuid.uuid4) name = models.CharField(max_length=100) def __str__(self): return self.name class MultiOrderedModel(TreeNode): first_position = models.PositiveIntegerField(default=0) second_position = models.PositiveIntegerField(default=0) name = models.CharField(max_length=100) class Meta: ordering = ("first_position",) def __str__(self): return self.name class TreeNodeIsOptional(models.Model): parent = models.ForeignKey("self", null=True, on_delete=models.CASCADE) objects = TreeQuerySet.as_manager() def __str__(self): return "" class InheritParentModel(TreeNode): name = models.CharField(max_length=100) class InheritChildModel(InheritParentModel): pass class InheritGrandChildModel(InheritChildModel): pass class InheritAbstractChildModel(InheritParentModel): class Meta: abstract = True class InheritConcreteGrandChildModel(InheritAbstractChildModel): pass class RelatedOrderModel(TreeNode): name = models.CharField(max_length=100) class OneToOneRelatedOrder(models.Model): relatedmodel = models.OneToOneField( RelatedOrderModel, on_delete=models.CASCADE, primary_key=True, related_name="related", ) order = models.PositiveIntegerField(default=0) def __str__(self): return "" django-tree-queries-0.19/tests/testapp/settings.py000066400000000000000000000047061461246344200223660ustar00rootroot00000000000000import os DATABASES = { "default": { "ENGINE": "django.db.backends.%s" % os.getenv("DB_BACKEND", "sqlite3"), "NAME": os.getenv("DB_NAME", ":memory:"), "USER": os.getenv("DB_USER"), "PASSWORD": os.getenv("DB_PASSWORD"), "HOST": os.getenv("DB_HOST", ""), "PORT": os.getenv("DB_PORT", ""), "TEST": { "USER": "default_test", }, }, } DEFAULT_AUTO_FIELD = "django.db.models.AutoField" if os.environ.get("DB_BACKEND") in {"mysql", "mariadb"}: DATABASES["default"]["OPTIONS"] = { "init_command": "SET GLOBAL sql_mode=(SELECT REPLACE(@@sql_mode,'ONLY_FULL_GROUP_BY',''));" } INSTALLED_APPS = [ # "django.contrib.auth", # "django.contrib.admin", # "django.contrib.contenttypes", # "django.contrib.sessions", # "django.contrib.staticfiles", # "django.contrib.messages", "testapp", # "tree_queries", ] USE_TZ = True MEDIA_ROOT = "/media/" STATIC_URL = "/static/" BASEDIR = os.path.dirname(__file__) MEDIA_ROOT = os.path.join(BASEDIR, "media/") STATIC_ROOT = os.path.join(BASEDIR, "static/") SECRET_KEY = "supersikret" LOGIN_REDIRECT_URL = "/?login=1" ROOT_URLCONF = "testapp.urls" LANGUAGES = (("en", "English"), ("de", "German")) TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", "DIRS": [], "APP_DIRS": True, "OPTIONS": { "context_processors": [ "django.template.context_processors.debug", "django.template.context_processors.request", "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", ] }, } ] MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.locale.LocaleMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", ] if os.getenv("SQL"): # pragma: no cover from django.utils.log import DEFAULT_LOGGING as LOGGING LOGGING["handlers"]["console"]["level"] = "DEBUG" LOGGING["loggers"]["django.db.backends"] = { "level": "DEBUG", "handlers": ["console"], "propagate": False, } django-tree-queries-0.19/tests/testapp/test_queries.py000066400000000000000000001006711461246344200232400ustar00rootroot00000000000000from types import SimpleNamespace from django import forms from django.core.exceptions import ValidationError from django.db import connections, models from django.db.models import Count, Q, Sum from django.db.models.expressions import RawSQL from django.test import TestCase, override_settings from testapp.models import ( AlwaysTreeQueryModel, AlwaysTreeQueryModelCategory, InheritChildModel, InheritConcreteGrandChildModel, InheritGrandChildModel, InheritParentModel, Model, MultiOrderedModel, OneToOneRelatedOrder, ReferenceModel, RelatedOrderModel, StringOrderedModel, TreeNodeIsOptional, UnorderedModel, UUIDModel, ) from tree_queries.compiler import SEPARATOR, TreeQuery from tree_queries.query import pk @override_settings(DEBUG=True) class Test(TestCase): def create_tree(self): tree = SimpleNamespace() tree.root = Model.objects.create(name="root") tree.child1 = Model.objects.create(parent=tree.root, order=0, name="1") tree.child2 = Model.objects.create(parent=tree.root, order=1, name="2") tree.child1_1 = Model.objects.create(parent=tree.child1, order=0, name="1-1") tree.child2_1 = Model.objects.create(parent=tree.child2, order=0, name="2-1") tree.child2_2 = Model.objects.create(parent=tree.child2, order=42, name="2-2") return tree def test_stuff(self): Model.objects.create() self.assertEqual(len(Model.objects.with_tree_fields()), 1) instance = Model.objects.with_tree_fields().get() self.assertEqual(instance.tree_depth, 0) self.assertEqual(instance.tree_path, [instance.pk]) def test_no_attributes(self): tree = self.create_tree() root = Model.objects.get(pk=tree.root.pk) self.assertFalse(hasattr(root, "tree_depth")) self.assertFalse(hasattr(root, "tree_ordering")) self.assertFalse(hasattr(root, "tree_path")) def test_attributes(self): tree = self.create_tree() # Ordering should be deterministic child2_2 = ( Model.objects.with_tree_fields() .order_siblings_by("order", "pk") .get(pk=tree.child2_2.pk) ) self.assertEqual(child2_2.tree_depth, 2) # Tree ordering is an array of the ranks assigned to a comment's # ancestors when they are ordered without respect for tree relations. self.assertEqual(child2_2.tree_ordering, [1, 5, 6]) self.assertEqual( child2_2.tree_path, [tree.root.pk, tree.child2.pk, tree.child2_2.pk] ) def test_ancestors(self): tree = self.create_tree() with self.assertNumQueries(2): self.assertEqual(list(tree.child2_2.ancestors()), [tree.root, tree.child2]) self.assertEqual( list(tree.child2_2.ancestors(include_self=True)), [tree.root, tree.child2, tree.child2_2], ) self.assertEqual( list(tree.child2_2.ancestors().reverse()), [tree.child2, tree.root] ) self.assertEqual(list(tree.root.ancestors()), []) self.assertEqual(list(tree.root.ancestors(include_self=True)), [tree.root]) child2_2 = Model.objects.with_tree_fields().get(pk=tree.child2_2.pk) with self.assertNumQueries(1): self.assertEqual(list(child2_2.ancestors()), [tree.root, tree.child2]) def test_descendants(self): tree = self.create_tree() self.assertEqual( list(tree.child2.descendants()), [tree.child2_1, tree.child2_2] ) self.assertEqual( list(tree.child2.descendants(include_self=True)), [tree.child2, tree.child2_1, tree.child2_2], ) def test_queryset_or(self): tree = self.create_tree() qs = Model.objects.with_tree_fields() self.assertEqual( list(qs.filter(pk=tree.child1.pk) | qs.filter(pk=tree.child2.pk)), [tree.child1, tree.child2], ) def test_twice(self): self.assertEqual(list(Model.objects.with_tree_fields().with_tree_fields()), []) def test_boring_coverage(self): with self.assertRaises(ValueError): TreeQuery(Model).get_compiler() def test_count(self): tree = self.create_tree() self.assertEqual(Model.objects.count(), 6) self.assertEqual(Model.objects.with_tree_fields().count(), 6) self.assertEqual(Model.objects.with_tree_fields().distinct().count(), 6) self.assertEqual(list(Model.objects.descendants(tree.child1)), [tree.child1_1]) self.assertEqual(Model.objects.descendants(tree.child1).count(), 1) self.assertEqual(Model.objects.descendants(tree.child1).distinct().count(), 1) # .distinct() shouldn't always remove tree fields qs = list(Model.objects.with_tree_fields().distinct()) self.assertEqual(qs[0].tree_depth, 0) self.assertEqual(qs[5].tree_depth, 2) def test_annotate(self): tree = self.create_tree() self.assertEqual( [ (node, node.children__count, node.tree_depth) for node in Model.objects.with_tree_fields().annotate(Count("children")) ], [ (tree.root, 2, 0), (tree.child1, 1, 1), (tree.child1_1, 0, 2), (tree.child2, 2, 1), (tree.child2_1, 0, 2), (tree.child2_2, 0, 2), ], ) def test_update_aggregate(self): self.create_tree() Model.objects.with_tree_fields().update(order=3) self.assertEqual( Model.objects.with_tree_fields().aggregate(Sum("order")), {"order__sum": 18}, # TODO Sum("tree_depth") does not work because the field is not # known yet. ) def test_values(self): self.create_tree() self.assertEqual( list(Model.objects.with_tree_fields().values("name")), [ {"name": "root"}, {"name": "1"}, {"name": "1-1"}, {"name": "2"}, {"name": "2-1"}, {"name": "2-2"}, ], ) def test_values_ancestors(self): tree = self.create_tree() self.assertEqual( list(Model.objects.ancestors(tree.child2_1).values()), [ { "custom_id": tree.root.pk, "name": "root", "order": 0, "parent_id": None, }, { "custom_id": tree.child2.pk, "name": "2", "order": 1, "parent_id": tree.root.pk, }, ], ) def test_values_list(self): self.create_tree() self.assertEqual( list(Model.objects.with_tree_fields().values_list("name", flat=True)), ["root", "1", "1-1", "2", "2-1", "2-2"], ) def test_values_list_ancestors(self): tree = self.create_tree() self.assertEqual( list( Model.objects.ancestors(tree.child2_1).values_list("parent", flat=True) ), [tree.root.parent_id, tree.child2.parent_id], ) def test_loops(self): tree = self.create_tree() tree.root.parent_id = tree.child1.pk with self.assertRaises(ValidationError) as cm: tree.root.full_clean() self.assertEqual( cm.exception.messages, ["A node cannot be made a descendant of itself."] ) # No error. tree.child1.full_clean() def test_unordered(self): self.assertEqual(list(UnorderedModel.objects.all()), []) def test_revert(self): tree = self.create_tree() obj = ( Model.objects.with_tree_fields().without_tree_fields().get(pk=tree.root.pk) ) self.assertFalse(hasattr(obj, "tree_depth")) def test_form_field(self): tree = self.create_tree() class Form(forms.ModelForm): class Meta: model = Model fields = ["parent"] html = f"{Form().as_table()}" self.assertIn(f'', html) self.assertIn("root", html) class OtherForm(forms.Form): node = Model._meta.get_field("parent").formfield( label_from_instance=lambda obj: "{}{}".format( "".join( ["*** " if obj == tree.child2_1 else "--- "] * obj.tree_depth ), obj, ), queryset=tree.child2.descendants(), ) html = f"{OtherForm().as_table()}" self.assertIn(f'', html) self.assertNotIn("root", html) def test_string_ordering(self): tree = SimpleNamespace() tree.americas = StringOrderedModel.objects.create(name="Americas") tree.europe = StringOrderedModel.objects.create(name="Europe") tree.france = StringOrderedModel.objects.create( name="France", parent=tree.europe ) tree.south_america = StringOrderedModel.objects.create( name="South America", parent=tree.americas ) tree.ecuador = StringOrderedModel.objects.create( name="Ecuador", parent=tree.south_america ) tree.colombia = StringOrderedModel.objects.create( name="Colombia", parent=tree.south_america ) tree.peru = StringOrderedModel.objects.create( name="Peru", parent=tree.south_america ) tree.north_america = StringOrderedModel.objects.create( name="North America", parent=tree.americas ) self.assertEqual( list(StringOrderedModel.objects.with_tree_fields()), [ tree.americas, tree.north_america, tree.south_america, tree.colombia, tree.ecuador, tree.peru, tree.europe, tree.france, ], ) self.assertEqual( list(tree.peru.ancestors(include_self=True)), [tree.americas, tree.south_america, tree.peru], ) self.assertEqual( list( StringOrderedModel.objects.descendants(tree.americas, include_self=True) ), [ tree.americas, tree.north_america, tree.south_america, tree.colombia, tree.ecuador, tree.peru, ], ) def test_many_ordering(self): root = Model.objects.create(order=1, name="root") for i in range(20, 0, -1): Model.objects.create(parent=root, name=f"Node {i}", order=i * 10) positions = [m.order for m in Model.objects.with_tree_fields()] self.assertEqual(positions, sorted(positions)) def test_bfs_ordering(self): tree = self.create_tree() nodes = Model.objects.with_tree_fields().extra( order_by=["__tree.tree_depth", "__tree.tree_ordering"] ) self.assertEqual( list(nodes), [ tree.root, tree.child1, tree.child2, tree.child1_1, tree.child2_1, tree.child2_2, ], ) def test_always_tree_query(self): AlwaysTreeQueryModel.objects.create(name="Nothing") obj = AlwaysTreeQueryModel.objects.get() self.assertTrue(hasattr(obj, "tree_depth")) self.assertTrue(hasattr(obj, "tree_ordering")) self.assertTrue(hasattr(obj, "tree_path")) self.assertEqual(obj.tree_depth, 0) AlwaysTreeQueryModel.objects.update(name="Something") obj.refresh_from_db() self.assertEqual(obj.name, "Something") AlwaysTreeQueryModel.objects.all().delete() def test_always_tree_query_relations(self): c = AlwaysTreeQueryModelCategory.objects.create() m1 = AlwaysTreeQueryModel.objects.create(name="Nothing", category=c) m2 = AlwaysTreeQueryModel.objects.create(name="Something") m1.related.add(m2) m3 = m2.related.get() self.assertEqual(m1, m3) self.assertEqual(m3.tree_depth, 0) m4 = c.instances.get() self.assertEqual(m1, m4) self.assertEqual(m4.tree_depth, 0) def test_reference(self): tree = self.create_tree() references = SimpleNamespace() references.none = ReferenceModel.objects.create(position=0) references.root = ReferenceModel.objects.create( position=1, tree_field=tree.root ) references.child1 = ReferenceModel.objects.create( position=2, tree_field=tree.child1 ) references.child2 = ReferenceModel.objects.create( position=3, tree_field=tree.child2 ) references.child1_1 = ReferenceModel.objects.create( position=4, tree_field=tree.child1_1 ) references.child2_1 = ReferenceModel.objects.create( position=5, tree_field=tree.child2_1 ) references.child2_2 = ReferenceModel.objects.create( position=6, tree_field=tree.child2_2 ) self.assertEqual( list( ReferenceModel.objects.filter( tree_field__in=tree.child2.descendants(include_self=True) ) ), [references.child2, references.child2_1, references.child2_2], ) self.assertEqual( list( ReferenceModel.objects.filter( Q(tree_field__in=tree.child2.ancestors(include_self=True)) | Q(tree_field__in=tree.child2.descendants(include_self=True)) ) ), [ references.root, references.child2, references.child2_1, references.child2_2, ], ) self.assertEqual( list( ReferenceModel.objects.filter( Q(tree_field__in=tree.child2_2.descendants(include_self=True)) | Q(tree_field__in=tree.child1.descendants()) | Q(tree_field__in=tree.child1.ancestors()) ) ), [references.root, references.child1_1, references.child2_2], ) self.assertEqual( list( ReferenceModel.objects.exclude( Q(tree_field__in=tree.child2.ancestors(include_self=True)) | Q(tree_field__in=tree.child2.descendants(include_self=True)) | Q(tree_field__isnull=True) ) ), [references.child1, references.child1_1], ) self.assertEqual( list( ReferenceModel.objects.exclude( Q(tree_field__in=tree.child2.descendants()) | Q(tree_field__in=tree.child2.ancestors()) | Q(tree_field__in=tree.child1.descendants(include_self=True)) | Q(tree_field__in=tree.child1.ancestors()) ) ), [references.none, references.child2], ) self.assertEqual( list( ReferenceModel.objects.filter( Q( Q(tree_field__in=tree.child2.descendants()) & ~Q(id=references.child2_2.id) ) | Q(tree_field__isnull=True) | Q(tree_field__in=tree.child1.ancestors()) ) ), [references.none, references.root, references.child2_1], ) self.assertEqual( list( ReferenceModel.objects.filter( tree_field__in=tree.child2.descendants(include_self=True).filter( parent__in=tree.child2.descendants(include_self=True) ) ) ), [references.child2_1, references.child2_2], ) def test_reference_isnull_issue63(self): # https://github.com/feincms/django-tree-queries/issues/63 self.assertSequenceEqual( Model.objects.with_tree_fields().exclude(referencemodel__isnull=False), [] ) def test_annotate_tree(self): tree = self.create_tree() qs = Model.objects.with_tree_fields().filter( Q(pk__in=tree.child2.ancestors(include_self=True)) | Q(pk__in=tree.child2.descendants(include_self=True)) ) if connections[Model.objects.db].vendor == "postgresql": qs = qs.annotate( is_my_field=RawSQL( "%s = ANY(__tree.tree_path)", [pk(tree.child2_1)], output_field=models.BooleanField(), ) ) else: qs = qs.annotate( is_my_field=RawSQL( f'instr(__tree.tree_path, "{SEPARATOR}{pk(tree.child2_1)}{SEPARATOR}") <> 0', [], output_field=models.BooleanField(), ) ) self.assertEqual( [(node, node.is_my_field) for node in qs], [ (tree.root, False), (tree.child2, False), (tree.child2_1, True), (tree.child2_2, False), ], ) def test_uuid_queries(self): root = UUIDModel.objects.create(name="root") child1 = UUIDModel.objects.create(parent=root, name="child1") child2 = UUIDModel.objects.create(parent=root, name="child2") self.assertCountEqual( root.descendants(), {child1, child2}, ) self.assertEqual( list(child1.ancestors(include_self=True)), [root, child1], ) def test_sibling_ordering(self): tree = SimpleNamespace() tree.root = MultiOrderedModel.objects.create(name="root") tree.child1 = MultiOrderedModel.objects.create( parent=tree.root, first_position=0, second_position=1, name="1" ) tree.child2 = MultiOrderedModel.objects.create( parent=tree.root, first_position=1, second_position=0, name="2" ) tree.child1_1 = MultiOrderedModel.objects.create( parent=tree.child1, first_position=0, second_position=1, name="1-1" ) tree.child2_1 = MultiOrderedModel.objects.create( parent=tree.child2, first_position=0, second_position=1, name="2-1" ) tree.child2_2 = MultiOrderedModel.objects.create( parent=tree.child2, first_position=1, second_position=0, name="2-2" ) first_order = [ tree.root, tree.child1, tree.child1_1, tree.child2, tree.child2_1, tree.child2_2, ] second_order = [ tree.root, tree.child2, tree.child2_2, tree.child2_1, tree.child1, tree.child1_1, ] nodes = MultiOrderedModel.objects.order_siblings_by("second_position") self.assertEqual(list(nodes), second_order) nodes = MultiOrderedModel.objects.with_tree_fields() self.assertEqual(list(nodes), first_order) nodes = MultiOrderedModel.objects.order_siblings_by("second_position").all() self.assertEqual(list(nodes), second_order) def test_depth_filter(self): tree = self.create_tree() nodes = Model.objects.with_tree_fields().extra( where=["__tree.tree_depth between %s and %s"], params=[0, 1], ) self.assertEqual( list(nodes), [ tree.root, tree.child1, # tree.child1_1, tree.child2, # tree.child2_1, # tree.child2_2, ], ) def test_explain(self): if connections[Model.objects.db].vendor == "postgresql": explanation = Model.objects.with_tree_fields().explain() self.assertIn("CTE", explanation) def test_tree_queries_without_tree_node(self): TreeNodeIsOptional.objects.create(parent=TreeNodeIsOptional.objects.create()) nodes = list(TreeNodeIsOptional.objects.with_tree_fields()) self.assertEqual(nodes[0].tree_depth, 0) self.assertEqual(nodes[1].tree_depth, 1) def test_polymorphic_queries(self): """test queries on concrete child classes in multi-table inheritance setup""" # create a tree with a random mix of classes/subclasses root = InheritChildModel.objects.create(name="root") child1 = InheritGrandChildModel.objects.create(parent=root, name="child1") child2 = InheritParentModel.objects.create(parent=root, name="child2") InheritParentModel.objects.create(parent=child1, name="child1_1") InheritChildModel.objects.create(parent=child2, name="child2_1") InheritConcreteGrandChildModel.objects.create(parent=child2, name="child2_2") # ensure we get the full tree if querying the super class objs = InheritParentModel.objects.with_tree_fields() self.assertCountEqual( [(p.name, p.tree_path) for p in objs], [ ("root", [1]), ("child1", [1, 2]), ("child1_1", [1, 2, 4]), ("child2", [1, 3]), ("child2_1", [1, 3, 5]), ("child2_2", [1, 3, 6]), ], ) # ensure we still get the tree when querying only a subclass (including sub-subclasses) objs = InheritChildModel.objects.with_tree_fields() self.assertCountEqual( [(p.name, p.tree_path) for p in objs], [ ("root", [1]), ("child1", [1, 2]), ("child2_1", [1, 3, 5]), ], ) # ensure we still get the tree when querying only a subclass objs = InheritGrandChildModel.objects.with_tree_fields() self.assertCountEqual( [(p.name, p.tree_path) for p in objs], [ ("child1", [1, 2]), ], ) # ensure we don't get confused by an intermediate abstract subclass objs = InheritConcreteGrandChildModel.objects.with_tree_fields() self.assertCountEqual( [(p.name, p.tree_path) for p in objs], [ ("child2_2", [1, 3, 6]), ], ) def test_descending_order(self): tree = self.create_tree() nodes = Model.objects.order_siblings_by("-order") self.assertEqual( list(nodes), [ tree.root, tree.child2, tree.child2_2, tree.child2_1, tree.child1, tree.child1_1, ], ) def test_multi_field_order(self): tree = SimpleNamespace() tree.root = MultiOrderedModel.objects.create(name="root") tree.child1 = MultiOrderedModel.objects.create( parent=tree.root, first_position=0, second_position=1, name="1" ) tree.child2 = MultiOrderedModel.objects.create( parent=tree.root, first_position=0, second_position=0, name="2" ) tree.child1_1 = MultiOrderedModel.objects.create( parent=tree.child1, first_position=1, second_position=1, name="1-1" ) tree.child2_1 = MultiOrderedModel.objects.create( parent=tree.child2, first_position=0, second_position=1, name="2-1" ) tree.child2_2 = MultiOrderedModel.objects.create( parent=tree.child2, first_position=1, second_position=0, name="2-2" ) nodes = MultiOrderedModel.objects.order_siblings_by( "first_position", "-second_position" ) self.assertEqual( list(nodes), [ tree.root, tree.child1, tree.child1_1, tree.child2, tree.child2_1, tree.child2_2, ], ) def test_order_by_related(self): tree = SimpleNamespace() tree.root = RelatedOrderModel.objects.create(name="root") tree.child1 = RelatedOrderModel.objects.create(parent=tree.root, name="1") tree.child1_related = OneToOneRelatedOrder.objects.create( relatedmodel=tree.child1, order=0 ) tree.child2 = RelatedOrderModel.objects.create(parent=tree.root, name="2") tree.child2_related = OneToOneRelatedOrder.objects.create( relatedmodel=tree.child2, order=1 ) tree.child1_1 = RelatedOrderModel.objects.create(parent=tree.child1, name="1-1") tree.child1_1_related = OneToOneRelatedOrder.objects.create( relatedmodel=tree.child1_1, order=0 ) tree.child2_1 = RelatedOrderModel.objects.create(parent=tree.child2, name="2-1") tree.child2_1_related = OneToOneRelatedOrder.objects.create( relatedmodel=tree.child2_1, order=0 ) tree.child2_2 = RelatedOrderModel.objects.create(parent=tree.child2, name="2-2") tree.child2_2_related = OneToOneRelatedOrder.objects.create( relatedmodel=tree.child2_2, order=1 ) nodes = RelatedOrderModel.objects.order_siblings_by("related__order") self.assertEqual( list(nodes), [ tree.root, tree.child1, tree.child1_1, tree.child2, tree.child2_1, tree.child2_2, ], ) def test_tree_exclude(self): tree = self.create_tree() # Tree-filter should remove children if # the parent meets the filtering criteria nodes = Model.objects.tree_exclude(name="2") self.assertEqual( list(nodes), [ tree.root, tree.child1, tree.child1_1, ], ) def test_tree_filter(self): tree = self.create_tree() # Tree-filter should remove children if # the parent does not meet the filtering criteria nodes = Model.objects.tree_filter(name__in=["root", "1-1", "2", "2-1", "2-2"]) self.assertEqual( list(nodes), [ tree.root, tree.child2, tree.child2_1, tree.child2_2, ], ) def test_tree_filter_chaining(self): tree = self.create_tree() # Tree-filter should remove children if # the parent does not meet the filtering criteria nodes = Model.objects.tree_exclude(name="2-2").tree_filter( name__in=["root", "1-1", "2", "2-1", "2-2"] ) self.assertEqual( list(nodes), [ tree.root, tree.child2, tree.child2_1, ], ) def test_tree_filter_related(self): tree = SimpleNamespace() tree.root = RelatedOrderModel.objects.create(name="root") tree.root_related = OneToOneRelatedOrder.objects.create( relatedmodel=tree.root, order=0 ) tree.child1 = RelatedOrderModel.objects.create(parent=tree.root, name="1") tree.child1_related = OneToOneRelatedOrder.objects.create( relatedmodel=tree.child1, order=0 ) tree.child2 = RelatedOrderModel.objects.create(parent=tree.root, name="2") tree.child2_related = OneToOneRelatedOrder.objects.create( relatedmodel=tree.child2, order=1 ) tree.child1_1 = RelatedOrderModel.objects.create(parent=tree.child1, name="1-1") tree.child1_1_related = OneToOneRelatedOrder.objects.create( relatedmodel=tree.child1_1, order=0 ) tree.child2_1 = RelatedOrderModel.objects.create(parent=tree.child2, name="2-1") tree.child2_1_related = OneToOneRelatedOrder.objects.create( relatedmodel=tree.child2_1, order=0 ) tree.child2_2 = RelatedOrderModel.objects.create(parent=tree.child2, name="2-2") tree.child2_2_related = OneToOneRelatedOrder.objects.create( relatedmodel=tree.child2_2, order=1 ) nodes = RelatedOrderModel.objects.tree_filter(related__order=0) self.assertEqual( list(nodes), [ tree.root, tree.child1, tree.child1_1, ], ) def test_tree_filter_with_order(self): tree = SimpleNamespace() tree.root = MultiOrderedModel.objects.create( name="root", first_position=1, ) tree.child1 = MultiOrderedModel.objects.create( parent=tree.root, first_position=0, second_position=1, name="1" ) tree.child2 = MultiOrderedModel.objects.create( parent=tree.root, first_position=1, second_position=0, name="2" ) tree.child1_1 = MultiOrderedModel.objects.create( parent=tree.child1, first_position=1, second_position=1, name="1-1" ) tree.child2_1 = MultiOrderedModel.objects.create( parent=tree.child2, first_position=1, second_position=1, name="2-1" ) tree.child2_2 = MultiOrderedModel.objects.create( parent=tree.child2, first_position=1, second_position=0, name="2-2" ) nodes = MultiOrderedModel.objects.tree_filter( first_position__gt=0 ).order_siblings_by("-second_position") self.assertEqual( list(nodes), [ tree.root, tree.child2, tree.child2_1, tree.child2_2, ], ) def test_tree_filter_q_objects(self): tree = self.create_tree() # Tree-filter should remove children if # the parent does not meet the filtering criteria nodes = Model.objects.tree_filter( Q(name__in=["root", "1-1", "2", "2-1", "2-2"]) ) self.assertEqual( list(nodes), [ tree.root, tree.child2, tree.child2_1, tree.child2_2, ], ) def test_tree_filter_q_mix(self): tree = SimpleNamespace() tree.root = MultiOrderedModel.objects.create( name="root", first_position=1, second_position=2 ) tree.child1 = MultiOrderedModel.objects.create( parent=tree.root, first_position=1, second_position=0, name="1" ) tree.child2 = MultiOrderedModel.objects.create( parent=tree.root, first_position=1, second_position=2, name="2" ) tree.child1_1 = MultiOrderedModel.objects.create( parent=tree.child1, first_position=1, second_position=1, name="1-1" ) tree.child2_1 = MultiOrderedModel.objects.create( parent=tree.child2, first_position=1, second_position=1, name="2-1" ) tree.child2_2 = MultiOrderedModel.objects.create( parent=tree.child2, first_position=1, second_position=2, name="2-2" ) # Tree-filter should remove children if # the parent does not meet the filtering criteria nodes = MultiOrderedModel.objects.tree_filter( Q(first_position=1), second_position=2 ) self.assertEqual( list(nodes), [ tree.root, tree.child2, tree.child2_2, ], ) def test_tree_fields(self): self.create_tree() qs = Model.objects.tree_fields(tree_names="name", tree_orders="order") names = [obj.tree_names for obj in qs] self.assertEqual( names, [ ["root"], ["root", "1"], ["root", "1", "1-1"], ["root", "2"], ["root", "2", "2-1"], ["root", "2", "2-2"], ], ) orders = [obj.tree_orders for obj in qs] self.assertEqual( orders, [[0], [0, 0], [0, 0, 0], [0, 1], [0, 1, 0], [0, 1, 42]] ) # ids = [obj.tree_pks for obj in Model.objects.tree_fields(tree_pks="custom_id")] # self.assertIsInstance(ids[0][0], int) # ids = [obj.tree_pks for obj in Model.objects.tree_fields(tree_pks="parent_id")] # self.assertEqual(ids[0], [""]) django-tree-queries-0.19/tests/testapp/urls.py000066400000000000000000000002011461246344200214750ustar00rootroot00000000000000# from django.conf.urls import url # from django.contrib import admin urlpatterns = [ # url(r"^admin/", admin.site.urls) ] django-tree-queries-0.19/tox.ini000066400000000000000000000030351461246344200166370ustar00rootroot00000000000000[tox] envlist = docs py{38,39,310}-dj{32,41,42}-{sqlite,postgresql,mysql} py{310,311,312}-dj{32,41,42,50,main}-{sqlite,postgresql,mysql} [testenv] deps = dj32: Django>=3.2,<4.0 dj41: Django>=4.1,<4.2 dj42: Django>=4.2,<5.0 dj50: Django>=5.0,<5.1 djmain: https://github.com/django/django/archive/main.tar.gz postgresql: psycopg2-binary mysql: mysqlclient passenv= CI DB_BACKEND DB_NAME DB_USER DB_PASSWORD DB_HOST DB_PORT GITHUB_* SQL setenv = PYTHONPATH = {toxinidir} PYTHONWARNINGS = d DB_NAME = {env:DB_NAME:tree_queries} DB_USER = {env:DB_USER:tree_queries} DB_HOST = {env:DB_HOST:localhost} DB_PASSWORD = {env:DB_PASSWORD:tree_queries} pip_pre = True commands = python tests/manage.py test -v 2 {posargs:testapp} [testenv:py{38,39,310,311,312}-dj{32,41,42,50,main}-postgresql] setenv = {[testenv]setenv} DB_BACKEND = postgresql DB_PORT = {env:DB_PORT:5432} [testenv:py{38,39,310,311,312}-dj{32,41,42,50,main}-mysql] setenv = {[testenv]setenv} DB_BACKEND = mysql DB_PORT = {env:DB_PORT:3306} [testenv:py{38,39,310,311,312}-dj{32,41,42,50,main}-sqlite] setenv = {[testenv]setenv} DB_BACKEND = sqlite3 DB_NAME = ":memory:" [testenv:docs] commands = make -C {toxinidir}/docs html deps = Sphinx allowlist_externals = make [gh-actions] python = 3.8: py38 3.9: py39 3.10: py310 3.11: py311 3.12: py312 [gh-actions:env] DB_BACKEND = mysql: mysql postgresql: postgresql sqlite3: sqlite django-tree-queries-0.19/tree_queries/000077500000000000000000000000001461246344200200175ustar00rootroot00000000000000django-tree-queries-0.19/tree_queries/__init__.py000066400000000000000000000000271461246344200221270ustar00rootroot00000000000000__version__ = "0.19.0" django-tree-queries-0.19/tree_queries/compiler.py000066400000000000000000000332401461246344200222050ustar00rootroot00000000000000import django from django.db import connections from django.db.models import Expression, F, QuerySet, Value, Window from django.db.models.functions import RowNumber from django.db.models.sql.compiler import SQLCompiler from django.db.models.sql.query import Query SEPARATOR = "\x1f" def _find_tree_model(cls): return cls._meta.get_field("parent").model class TreeQuery(Query): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._setup_query() def _setup_query(self): """ Run on initialization and at the end of chaining. Any attributes that would normally be set in __init__() should go here instead. """ # We add the variables for `sibling_order` and `rank_table_query` here so they # act as instance variables which do not persist between user queries # the way class variables do # Only add the sibling_order attribute if the query doesn't already have one to preserve cloning behavior if not hasattr(self, "sibling_order"): # Add an attribute to control the ordering of siblings within trees opts = _find_tree_model(self.model)._meta self.sibling_order = opts.ordering if opts.ordering else opts.pk.attname # Only add the rank_table_query attribute if the query doesn't already have one to preserve cloning behavior if not hasattr(self, "rank_table_query"): # Create a default QuerySet for the rank_table to use # so we can avoid recursion self.rank_table_query = QuerySet(model=_find_tree_model(self.model)) if not hasattr(self, "tree_fields"): self.tree_fields = {} def get_compiler(self, using=None, connection=None, **kwargs): # Copied from django/db/models/sql/query.py if using is None and connection is None: raise ValueError("Need either using or connection") if using: connection = connections[using] # Difference: Not connection.ops.compiler, but our own compiler which # adds the CTE. # **kwargs passes on elide_empty from Django 4.0 onwards return TreeCompiler(self, connection, using, **kwargs) def get_sibling_order(self): return self.sibling_order def get_rank_table_query(self): return self.rank_table_query def get_tree_fields(self): return self.tree_fields class TreeCompiler(SQLCompiler): CTE_POSTGRESQL = """ WITH RECURSIVE __rank_table( {tree_fields_columns} "{pk}", "{parent}", "rank_order" ) AS ( {rank_table} ), __tree ( {tree_fields_names} "tree_depth", "tree_path", "tree_ordering", "tree_pk" ) AS ( SELECT {tree_fields_initial} 0, array[T.{pk}], array[T.rank_order], T."{pk}" FROM __rank_table T WHERE T."{parent}" IS NULL UNION ALL SELECT {tree_fields_recursive} __tree.tree_depth + 1, __tree.tree_path || T.{pk}, __tree.tree_ordering || T.rank_order, T."{pk}" FROM __rank_table T JOIN __tree ON T."{parent}" = __tree.tree_pk ) """ CTE_MYSQL = """ WITH RECURSIVE __rank_table( {tree_fields_columns} {pk}, {parent}, rank_order ) AS ( {rank_table} ), __tree( {tree_fields_names} tree_depth, tree_path, tree_ordering, tree_pk ) AS ( SELECT {tree_fields_initial} 0, -- Limit to max. 50 levels... CAST(CONCAT("{sep}", {pk}, "{sep}") AS char(1000)), CAST(CONCAT("{sep}", LPAD(CONCAT(T.rank_order, "{sep}"), 20, "0")) AS char(1000)), T.{pk} FROM __rank_table T WHERE T.{parent} IS NULL UNION ALL SELECT {tree_fields_recursive} __tree.tree_depth + 1, CONCAT(__tree.tree_path, T2.{pk}, "{sep}"), CONCAT(__tree.tree_ordering, LPAD(CONCAT(T2.rank_order, "{sep}"), 20, "0")), T2.{pk} FROM __tree, __rank_table T2 WHERE __tree.tree_pk = T2.{parent} ) """ CTE_SQLITE = """ WITH RECURSIVE __rank_table( {tree_fields_columns} {pk}, {parent}, rank_order ) AS ( {rank_table} ), __tree( {tree_fields_names} tree_depth, tree_path, tree_ordering, tree_pk ) AS ( SELECT {tree_fields_initial} 0, printf("{sep}%%s{sep}", {pk}), printf("{sep}%%020s{sep}", T.rank_order), T."{pk}" tree_pk FROM __rank_table T WHERE T."{parent}" IS NULL UNION ALL SELECT {tree_fields_recursive} __tree.tree_depth + 1, __tree.tree_path || printf("%%s{sep}", T.{pk}), __tree.tree_ordering || printf("%%020s{sep}", T.rank_order), T."{pk}" FROM __rank_table T JOIN __tree ON T."{parent}" = __tree.tree_pk ) """ def get_rank_table(self): # Get and validate sibling_order sibling_order = self.query.get_sibling_order() if isinstance(sibling_order, (list, tuple)): order_fields = sibling_order elif isinstance(sibling_order, str): order_fields = [sibling_order] else: raise ValueError( "Sibling order must be a string or a list or tuple of strings." ) # Convert strings to expressions. This is to maintain backwards compatibility # with Django versions < 4.1 if django.VERSION < (4, 1): base_order = [] for field in order_fields: if isinstance(field, Expression): base_order.append(field) elif isinstance(field, str): if field[0] == "-": base_order.append(F(field[1:]).desc()) else: base_order.append(F(field).asc()) order_fields = base_order # Get the rank table query rank_table_query = self.query.get_rank_table_query() rank_table_query = ( rank_table_query.order_by() # Ensure there is no ORDER BY at the end of the SQL # Values allows us to both limit and specify the order of # the columns selected so that they match the CTE .values( *self.query.get_tree_fields().values(), "pk", "parent", rank_order=Window( expression=RowNumber(), order_by=order_fields, ), ) ) rank_table_sql, rank_table_params = rank_table_query.query.sql_with_params() return rank_table_sql, rank_table_params def as_sql(self, *args, **kwargs): # Try detecting if we're used in a EXISTS(1 as "a") subquery like # Django's sql.Query.exists() generates. If we detect such a query # we're skipping the tree generation since it's not necessary in the # best case and references unused table aliases (leading to SQL errors) # in the worst case. See GitHub issue #63. if ( self.query.subquery and (ann := self.query.annotations) and ann == {"a": Value(1)} ): return super().as_sql(*args, **kwargs) # The general idea is that if we have a summary query (e.g. .count()) # then we do not want to ask Django to add the tree fields to the query # using .query.add_extra. The way to determine whether we have a # summary query on our hands is to check the is_summary attribute of # all annotations. # # A new case appeared in the GitHub issue #26: Queries using # .distinct().count() crashed. The reason for this is that Django uses # a distinct subquery *without* annotations -- the annotations are kept # in the surrounding query. Because of this we look at the distinct and # subquery attributes. # # I am not confident that this is the perfect way to approach this # problem but I just gotta stop worrying and trust the testsuite. skip_tree_fields = ( self.query.distinct and self.query.subquery ) or any( # pragma: no branch # OK if generator is not consumed completely annotation.is_summary for alias, annotation in self.query.annotations.items() ) opts = _find_tree_model(self.query.model)._meta params = { "parent": "parent_id", # XXX Hardcoded. "pk": opts.pk.attname, "db_table": opts.db_table, "sep": SEPARATOR, } # Get the rank_table SQL and params rank_table_sql, rank_table_params = self.get_rank_table() params["rank_table"] = rank_table_sql if self.connection.vendor == "postgresql": cte = self.CTE_POSTGRESQL cte_initial = "array[T.{column}]::text[], " cte_recursive = "__tree.{name} || T.{column}::text, " elif self.connection.vendor == "sqlite": cte = self.CTE_SQLITE cte_initial = 'printf("{sep}%%s{sep}", {column}), ' cte_recursive = '__tree.{name} || printf("%%s{sep}", T.{column}), ' elif self.connection.vendor == "mysql": cte = self.CTE_MYSQL cte_initial = 'CAST(CONCAT("{sep}", {column}, "{sep}") AS char(1000)), ' cte_recursive = 'CONCAT(__tree.{name}, T2.{column}, "{sep}"), ' tree_fields = self.query.get_tree_fields() qn = self.connection.ops.quote_name params.update({ "tree_fields_columns": "".join( f"{qn(column)}, " for column in tree_fields.values() ), "tree_fields_names": "".join(f"{qn(name)}, " for name in tree_fields), "tree_fields_initial": "".join( cte_initial.format(column=qn(column), name=qn(name), sep=SEPARATOR) for name, column in tree_fields.items() ), "tree_fields_recursive": "".join( cte_recursive.format(column=qn(column), name=qn(name), sep=SEPARATOR) for name, column in tree_fields.items() ), }) if "__tree" not in self.query.extra_tables: # pragma: no branch - unlikely tree_params = params.copy() # use aliased table name (U0, U1, U2) base_table = self.query.__dict__.get("base_table") if base_table is not None: tree_params["db_table"] = base_table # When using tree queries in subqueries our base table may use # an alias. Let's hope using the first alias is correct. aliases = self.query.table_map.get(tree_params["db_table"]) if aliases: tree_params["db_table"] = aliases[0] select = { "tree_depth": "__tree.tree_depth", "tree_path": "__tree.tree_path", "tree_ordering": "__tree.tree_ordering", } select.update({name: f"__tree.{name}" for name in tree_fields}) self.query.add_extra( # Do not add extra fields to the select statement when it is a # summary query or when using .values() or .values_list() select={} if skip_tree_fields or self.query.values_select else select, select_params=None, where=["__tree.tree_pk = {db_table}.{pk}".format(**tree_params)], params=None, tables=["__tree"], order_by=( [] # Do not add ordering for aggregates, or if the ordering # has already been specified using .extra() if skip_tree_fields or self.query.extra_order_by else ["__tree.tree_ordering"] # DFS is the only true way ), ) sql_0, sql_1 = super().as_sql(*args, **kwargs) explain = "" if sql_0.startswith("EXPLAIN "): explain, sql_0 = sql_0.split(" ", 1) # Pass any additional rank table sql paramaters so that the db backend can handle them. # This only works because we know that the CTE is at the start of the query. return ( "".join([explain, cte.format(**params), sql_0]), rank_table_params + sql_1, ) def get_converters(self, expressions): converters = super().get_converters(expressions) tree_fields = {"__tree.tree_path", "__tree.tree_ordering"} | { f"__tree.{name}" for name in self.query.tree_fields } for i, expression in enumerate(expressions): # We care about tree fields and annotations only if not hasattr(expression, "sql"): continue if expression.sql in tree_fields: converters[i] = ([converter], expression) return converters def converter(value, expression, connection, context=None): # context can be removed as soon as we only support Django>=2.0 if isinstance(value, str): # MySQL/MariaDB and sqlite3 do not support arrays. Split the value on # the ASCII unit separator (chr(31)). # NOTE: The representation of array is NOT part of the API. value = value.split(SEPARATOR)[1:-1] try: # Either all values are convertible to int or don't bother return [int(v) for v in value] # Maybe Field.to_python()? except ValueError: return value django-tree-queries-0.19/tree_queries/fields.py000066400000000000000000000006441461246344200216430ustar00rootroot00000000000000from django.db import models from tree_queries.forms import TreeNodeChoiceField class TreeNodeForeignKey(models.ForeignKey): def deconstruct(self): name, _path, args, kwargs = super().deconstruct() return (name, "django.db.models.ForeignKey", args, kwargs) def formfield(self, **kwargs): kwargs.setdefault("form_class", TreeNodeChoiceField) return super().formfield(**kwargs) django-tree-queries-0.19/tree_queries/forms.py000066400000000000000000000013201461246344200215130ustar00rootroot00000000000000from django import forms class TreeNodeIndentedLabels: def __init__(self, queryset, *args, **kwargs): if hasattr(queryset, "with_tree_fields"): queryset = queryset.with_tree_fields() if "label_from_instance" in kwargs: self.label_from_instance = kwargs.pop("label_from_instance") super().__init__(queryset, *args, **kwargs) def label_from_instance(self, obj): depth = getattr(obj, "tree_depth", 0) return "{}{}".format("".join(["--- "] * depth), obj) class TreeNodeChoiceField(TreeNodeIndentedLabels, forms.ModelChoiceField): pass class TreeNodeMultipleChoiceField( TreeNodeIndentedLabels, forms.ModelMultipleChoiceField ): pass django-tree-queries-0.19/tree_queries/locale/000077500000000000000000000000001461246344200212565ustar00rootroot00000000000000django-tree-queries-0.19/tree_queries/locale/de/000077500000000000000000000000001461246344200216465ustar00rootroot00000000000000django-tree-queries-0.19/tree_queries/locale/de/LC_MESSAGES/000077500000000000000000000000001461246344200234335ustar00rootroot00000000000000django-tree-queries-0.19/tree_queries/locale/de/LC_MESSAGES/django.mo000066400000000000000000000010411461246344200252260ustar00rootroot000000000000004L`-aB9 A node cannot be made a descendant of itself.parentProject-Id-Version: PACKAGE VERSION Report-Msgid-Bugs-To: PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE Last-Translator: FULL NAME Language-Team: LANGUAGE Language: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plural-Forms: nplurals=2; plural=(n != 1); Ein Element kann nicht Unterelement von sich selbst sein.übergeordnetdjango-tree-queries-0.19/tree_queries/locale/de/LC_MESSAGES/django.po000066400000000000000000000014641461246344200252420ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. # #, fuzzy msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2018-07-30 12:31+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "Language: \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" #: models.py:14 msgid "parent" msgstr "übergeordnet" #: models.py:56 msgid "A node cannot be made a descendant of itself." msgstr "Ein Element kann nicht Unterelement von sich selbst sein." django-tree-queries-0.19/tree_queries/models.py000066400000000000000000000031401461246344200216520ustar00rootroot00000000000000from django.core.exceptions import ValidationError from django.db import models from django.utils.translation import gettext_lazy as _ from tree_queries.fields import TreeNodeForeignKey from tree_queries.query import TreeQuerySet class TreeNode(models.Model): parent = TreeNodeForeignKey( "self", blank=True, null=True, on_delete=models.CASCADE, verbose_name=_("parent"), related_name="children", ) objects = TreeQuerySet.as_manager() class Meta: abstract = True def ancestors(self, **kwargs): """ Returns all ancestors of the current node See ``TreeQuerySet.ancestors`` for details and optional arguments. """ return self.__class__._default_manager.ancestors(self, **kwargs) def descendants(self, **kwargs): """ Returns all descendants of the current node See ``TreeQuerySet.descendants`` for details and optional arguments. """ return self.__class__._default_manager.descendants(self, **kwargs) def clean(self): """ Raises a validation error if saving this instance would result in loops in the tree structure """ super().clean() if ( self.parent_id and self.pk and ( self.__class__._default_manager.ancestors( self.parent_id, include_self=True ) .filter(pk=self.pk) .exists() ) ): raise ValidationError(_("A node cannot be made a descendant of itself.")) django-tree-queries-0.19/tree_queries/query.py000066400000000000000000000110311461246344200215320ustar00rootroot00000000000000from django.db import connections, models from django.db.models.sql.query import Query from tree_queries.compiler import SEPARATOR, TreeQuery def pk(of): """ Returns the primary key of the argument if it is an instance of a model, or the argument as-is otherwise """ return of.pk if hasattr(of, "pk") else of class TreeManager(models.Manager): def get_queryset(self): queryset = super().get_queryset() return queryset.with_tree_fields() if self._with_tree_fields else queryset class TreeQuerySet(models.QuerySet): def with_tree_fields(self, tree_fields=True): # noqa: FBT002 """ Requests tree fields on this queryset Pass ``False`` to revert to a queryset without tree fields. """ if tree_fields: self.query.__class__ = TreeQuery self.query._setup_query() else: self.query.__class__ = Query return self def without_tree_fields(self): """ Requests no tree fields on this queryset """ return self.with_tree_fields(tree_fields=False) def order_siblings_by(self, *order_by): """ Sets TreeQuery sibling_order attribute Pass the names of model fields as a list of strings to order tree siblings by those model fields """ self.query.__class__ = TreeQuery self.query._setup_query() self.query.sibling_order = order_by return self def tree_filter(self, *args, **kwargs): """ Adds a filter to the TreeQuery rank_table_query Takes the same arguements as a Django QuerySet .filter() """ self.query.__class__ = TreeQuery self.query._setup_query() self.query.rank_table_query = self.query.rank_table_query.filter( *args, **kwargs ) return self def tree_exclude(self, *args, **kwargs): """ Adds a filter to the TreeQuery rank_table_query Takes the same arguements as a Django QuerySet .exclude() """ self.query.__class__ = TreeQuery self.query._setup_query() self.query.rank_table_query = self.query.rank_table_query.exclude( *args, **kwargs ) return self def tree_fields(self, **tree_fields): self.query.__class__ = TreeQuery self.query._setup_query() self.query.tree_fields = tree_fields return self @classmethod def as_manager(cls, *, with_tree_fields=False): manager_class = TreeManager.from_queryset(cls) # Only used in deconstruct: manager_class._built_with_as_manager = True # Set attribute on class, not on the instance so that the automatic # subclass generation used e.g. for relations also finds this # attribute. manager_class._with_tree_fields = with_tree_fields return manager_class() as_manager.queryset_only = True def ancestors(self, of, *, include_self=False): """ Returns ancestors of the given node ordered from the root of the tree towards deeper levels, optionally including the node itself """ if not hasattr(of, "tree_path"): of = self.with_tree_fields().get(pk=pk(of)) ids = of.tree_path if include_self else of.tree_path[:-1] return ( self.with_tree_fields() # TODO tree fields not strictly required .filter(pk__in=ids) .extra(order_by=["__tree.tree_depth"]) ) def descendants(self, of, *, include_self=False): """ Returns descendants of the given node in depth-first order, optionally including and starting with the node itself """ connection = connections[self.db] if connection.vendor == "postgresql": queryset = self.with_tree_fields().extra( where=["%s = ANY(__tree.tree_path)"], params=[self.model._meta.pk.get_db_prep_value(pk(of), connection)], ) else: queryset = self.with_tree_fields().extra( # NOTE! The representation of tree_path is NOT part of the API. where=[ # XXX This *may* be unsafe with some primary key field types. # It is certainly safe with integers. f'instr(__tree.tree_path, "{SEPARATOR}{self.model._meta.pk.get_db_prep_value(pk(of), connection)}{SEPARATOR}") <> 0' ] ) if not include_self: return queryset.exclude(pk=pk(of)) return queryset