pax_global_header 0000666 0000000 0000000 00000000064 14612463442 0014520 g ustar 00root root 0000000 0000000 52 comment=ec23dc1d527f9ecf78ed8f141bf7f02cf21da3e8
django-tree-queries-0.19/ 0000775 0000000 0000000 00000000000 14612463442 0015323 5 ustar 00root root 0000000 0000000 django-tree-queries-0.19/.editorconfig 0000664 0000000 0000000 00000000310 14612463442 0017772 0 ustar 00root root 0000000 0000000 # 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/ 0000775 0000000 0000000 00000000000 14612463442 0016663 5 ustar 00root root 0000000 0000000 django-tree-queries-0.19/.github/FUNDING.yml 0000664 0000000 0000000 00000001252 14612463442 0020500 0 ustar 00root root 0000000 0000000 # 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/ 0000775 0000000 0000000 00000000000 14612463442 0020720 5 ustar 00root root 0000000 0000000 django-tree-queries-0.19/.github/workflows/test.yml 0000664 0000000 0000000 00000010035 14612463442 0022421 0 ustar 00root root 0000000 0000000 name: 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/.gitignore 0000664 0000000 0000000 00000000241 14612463442 0017310 0 ustar 00root root 0000000 0000000 *.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.yaml 0000664 0000000 0000000 00000002342 14612463442 0021605 0 ustar 00root root 0000000 0000000 exclude: ".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.rst 0000664 0000000 0000000 00000015475 14612463442 0017360 0 ustar 00root root 0000000 0000000 Change 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/LICENSE 0000664 0000000 0000000 00000003014 14612463442 0016326 0 ustar 00root root 0000000 0000000 Copyright (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.rst 0000664 0000000 0000000 00000015641 14612463442 0017021 0 ustar 00root root 0000000 0000000 ===================
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/ 0000775 0000000 0000000 00000000000 14612463442 0016253 5 ustar 00root root 0000000 0000000 django-tree-queries-0.19/docs/Makefile 0000664 0000000 0000000 00000001134 14612463442 0017712 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000002465 14612463442 0017561 0 ustar 00root root 0000000 0000000 import 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.rst 0000664 0000000 0000000 00000000071 14612463442 0020112 0 ustar 00root root 0000000 0000000 .. include:: ../README.rst
.. include:: ../CHANGELOG.rst
django-tree-queries-0.19/docs/make.bat 0000664 0000000 0000000 00000001375 14612463442 0017666 0 ustar 00root root 0000000 0000000 @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.toml 0000664 0000000 0000000 00000004301 14612463442 0020235 0 ustar 00root root 0000000 0000000 [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/ 0000775 0000000 0000000 00000000000 14612463442 0016465 5 ustar 00root root 0000000 0000000 django-tree-queries-0.19/tests/.gitignore 0000664 0000000 0000000 00000000024 14612463442 0020451 0 ustar 00root root 0000000 0000000 /.coverage
/htmlcov
django-tree-queries-0.19/tests/manage.py 0000775 0000000 0000000 00000000535 14612463442 0020275 0 ustar 00root root 0000000 0000000 #!/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/ 0000775 0000000 0000000 00000000000 14612463442 0020145 5 ustar 00root root 0000000 0000000 django-tree-queries-0.19/tests/testapp/__init__.py 0000664 0000000 0000000 00000000000 14612463442 0022244 0 ustar 00root root 0000000 0000000 django-tree-queries-0.19/tests/testapp/admin.py 0000664 0000000 0000000 00000000236 14612463442 0021610 0 ustar 00root root 0000000 0000000 from 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.py 0000664 0000000 0000000 00000005707 14612463442 0022013 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000004706 14612463442 0022366 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000100671 14612463442 0023240 0 ustar 00root root 0000000 0000000 from 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.py 0000664 0000000 0000000 00000000201 14612463442 0021475 0 ustar 00root root 0000000 0000000 # from django.conf.urls import url
# from django.contrib import admin
urlpatterns = [
# url(r"^admin/", admin.site.urls)
]
django-tree-queries-0.19/tox.ini 0000664 0000000 0000000 00000003035 14612463442 0016637 0 ustar 00root root 0000000 0000000 [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/ 0000775 0000000 0000000 00000000000 14612463442 0020017 5 ustar 00root root 0000000 0000000 django-tree-queries-0.19/tree_queries/__init__.py 0000664 0000000 0000000 00000000027 14612463442 0022127 0 ustar 00root root 0000000 0000000 __version__ = "0.19.0"
django-tree-queries-0.19/tree_queries/compiler.py 0000664 0000000 0000000 00000033240 14612463442 0022205 0 ustar 00root root 0000000 0000000 import 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.py 0000664 0000000 0000000 00000000644 14612463442 0021643 0 ustar 00root root 0000000 0000000 from 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.py 0000664 0000000 0000000 00000001320 14612463442 0021513 0 ustar 00root root 0000000 0000000 from 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/ 0000775 0000000 0000000 00000000000 14612463442 0021256 5 ustar 00root root 0000000 0000000 django-tree-queries-0.19/tree_queries/locale/de/ 0000775 0000000 0000000 00000000000 14612463442 0021646 5 ustar 00root root 0000000 0000000 django-tree-queries-0.19/tree_queries/locale/de/LC_MESSAGES/ 0000775 0000000 0000000 00000000000 14612463442 0023433 5 ustar 00root root 0000000 0000000 django-tree-queries-0.19/tree_queries/locale/de/LC_MESSAGES/django.mo 0000664 0000000 0000000 00000001041 14612463442 0025226 0 ustar 00root root 0000000 0000000 4 L ` - a B 9
A node cannot be made a descendant of itself. parent Project-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. übergeordnet django-tree-queries-0.19/tree_queries/locale/de/LC_MESSAGES/django.po 0000664 0000000 0000000 00000001464 14612463442 0025242 0 ustar 00root root 0000000 0000000 # 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.py 0000664 0000000 0000000 00000003140 14612463442 0021652 0 ustar 00root root 0000000 0000000 from 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.py 0000664 0000000 0000000 00000011031 14612463442 0021532 0 ustar 00root root 0000000 0000000 from 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