pax_global_header00006660000000000000000000000064150222235430014510gustar00rootroot0000000000000052 comment=9a961777843e1ce7fe25adde7245dd920ee8df0d django-tree-queries-0.20/000077500000000000000000000000001502222354300153035ustar00rootroot00000000000000django-tree-queries-0.20/.editorconfig000066400000000000000000000003101502222354300177520ustar00rootroot00000000000000# 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.20/.github/000077500000000000000000000000001502222354300166435ustar00rootroot00000000000000django-tree-queries-0.20/.github/FUNDING.yml000066400000000000000000000012521502222354300204600ustar00rootroot00000000000000# 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.20/.github/workflows/000077500000000000000000000000001502222354300207005ustar00rootroot00000000000000django-tree-queries-0.20/.github/workflows/test.yml000066400000000000000000000101011502222354300223730ustar00rootroot00000000000000name: 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", "3.13"] 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@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Get pip cache dir id: pip-cache run: | echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT - name: Cache uses: actions/cache@v4 with: path: ${{ steps.pip-cache.outputs.dir }} key: ${{ matrix.python-version }}-v1-${{ hashFiles('**/pyproject.toml') }}-${{ 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", "3.13"] 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@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Get pip cache dir id: pip-cache run: | echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT - name: Cache uses: actions/cache@v4 with: path: ${{ steps.pip-cache.outputs.dir }} key: ${{ matrix.python-version }}-v1-${{ hashFiles('**/pyproject.toml') }}-${{ 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", "3.13"] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Get pip cache dir id: pip-cache run: | echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT - name: Cache uses: actions/cache@v4 with: path: ${{ steps.pip-cache.outputs.dir }} key: ${{ matrix.python-version }}-v1-${{ hashFiles('**/pyproject.toml') }}-${{ 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.20/.gitignore000066400000000000000000000002411502222354300172700ustar00rootroot00000000000000*.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.20/.pre-commit-config.yaml000066400000000000000000000024101502222354300215610ustar00rootroot00000000000000exclude: ".yarn/|yarn.lock|\\.min\\.(css|js)$" repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.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.23.1 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.9.6" hooks: - id: ruff - id: ruff-format - repo: https://github.com/tox-dev/pyproject-fmt rev: v2.5.0 hooks: - id: pyproject-fmt - repo: https://github.com/abravalheri/validate-pyproject rev: v0.23 hooks: - id: validate-pyproject - repo: local hooks: - id: prettier name: prettier entry: npx prettier@3.4.2 --no-semi --write language: system types_or: [markdown, css, javascript] require_serial: true django-tree-queries-0.20/.readthedocs.yaml000066400000000000000000000005001502222354300205250ustar00rootroot00000000000000# Read the Docs configuration file # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details version: 2 build: os: ubuntu-24.04 tools: python: "3.11" sphinx: configuration: docs/conf.py # python: # install: # - requirements: docs/requirements.txt # - method: pip # path: . django-tree-queries-0.20/CHANGELOG.rst000066400000000000000000000167721502222354300173410ustar00rootroot00000000000000Change log ========== Next version ~~~~~~~~~~~~ 0.20 (2025-06-11) ~~~~~~~~~~~~~~~~~ - Added Python 3.13, Django 5.1 and 5.2 to the testsuite. - Added tests showing that ``.descendants().update(...)`` doesn't work, but ``.filter(pk__in=....descendants()).update(...)`` does. - Added Python 3.13 to the testsuite. - Converted the tests to use pytest. - Added a ``tree_info`` template tag and a ``recursetree`` template block. - Optimized the performance by avoiding the rank table altogether in the simple case of an ascending ordering on a single field. If that's not possible, the README now documents using ``.tree_filter()`` and ``.tree_exclude()`` to filter the queryset before running the recursive CTE. - Improved the test coverage. 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.20/LICENSE000066400000000000000000000030141502222354300163060ustar00rootroot00000000000000Copyright (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.20/README.rst000066400000000000000000000370251502222354300170010ustar00rootroot00000000000000=================== 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. - **Performance optimization**: The library automatically detects simple cases (single field ordering, no tree filters, no custom tree fields) and uses an optimized CTE that avoids creating a rank table, significantly improving performance for basic tree queries. 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 `__. - Use ``tree_filter()`` and ``tree_exclude()`` for better performance when working with large tables - these filter the base table before building the tree structure. - Use ``tree_fields()`` to aggregate ancestor field values into arrays. - 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") # Revert to a queryset without tree fields (improves performance). nodes = Node.objects.with_tree_fields().without_tree_fields() Filtering tree subsets ---------------------- **IMPORTANT**: For large tables, always use ``tree_filter()`` or ``tree_exclude()`` to limit which nodes are processed by the recursive CTE. Without these filters, the database evaluates the entire table, which can be extremely slow. .. code-block:: python # Get a specific tree from a forest by filtering on root category product_tree = Node.objects.with_tree_fields().tree_filter(category="products") # Get organizational chart for a specific department engineering_tree = Node.objects.with_tree_fields().tree_filter(department="engineering") # Exclude entire trees/sections you don't need content_trees = Node.objects.with_tree_fields().tree_exclude(category="archived") # Chain multiple tree filters for more specific trees recent_products = (Node.objects.with_tree_fields() .tree_filter(category="products") .tree_filter(created_date__gte=datetime.date.today())) # Get descendants within a filtered tree subset product_descendants = (Node.objects.with_tree_fields() .tree_filter(category="products") .descendants(some_product_node)) # Filter by site/tenant in multi-tenant applications site_content = Node.objects.with_tree_fields().tree_filter(site_id=request.site.id) Performance note: ``tree_filter()`` and ``tree_exclude()`` filter the base table before the recursive CTE processes relationships, dramatically improving performance for large datasets compared to using regular ``filter()`` after ``with_tree_fields()``. Best used for selecting complete trees or tree sections rather than scattered nodes. Note that the tree queryset doesn't support all types of queries Django supports. For example, updating all descendants directly isn't supported. The reason for that is that the recursive CTE isn't added to the UPDATE query correctly. Workarounds often include moving the tree query into a subquery: .. code-block:: python # Doesn't work: node.descendants().update(is_active=False) # Use this workaround instead: Node.objects.filter(pk__in=node.descendants()).update(is_active=False) 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], ) Aggregating ancestor fields --------------------------- Use ``tree_fields()`` to aggregate values from ancestor nodes into arrays. This is useful for collecting paths, permissions, categories, or any field that should be inherited down the tree hierarchy. .. code-block:: python # Aggregate names from all ancestors into an array nodes = Node.objects.with_tree_fields().tree_fields( tree_names="name", ) # Each node now has a tree_names attribute: ['root', 'parent', 'current'] # Aggregate multiple fields nodes = Node.objects.with_tree_fields().tree_fields( tree_names="name", tree_categories="category", tree_permissions="permission_level", ) # Build a full path string from ancestor names nodes = Node.objects.with_tree_fields().tree_fields(tree_names="name") for node in nodes: full_path = " > ".join(node.tree_names) # "Root > Section > Subsection" # Combine with tree filtering for better performance active_nodes = (Node.objects.with_tree_fields() .tree_filter(is_active=True) .tree_fields(tree_names="name")) The aggregated fields contain values from all ancestors (root to current node) in hierarchical order, including the current node itself. 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``. Templates ~~~~~~~~~ django-tree-queries includes template tags to help render tree structures in Django templates. These template tags are designed to work efficiently with tree querysets and respect queryset boundaries. Setup ----- Add ``tree_queries`` to your ``INSTALLED_APPS`` setting: .. code-block:: python INSTALLED_APPS = [ # ... other apps 'tree_queries', ] Then load the template tags in your template: .. code-block:: html {% load tree_queries %} tree_info filter ---------------- The ``tree_info`` filter provides detailed information about each node's position in the tree structure. It's useful when you need fine control over the tree rendering. .. code-block:: html {% load tree_queries %}
    {% for node, structure in nodes|tree_info %} {% if structure.new_level %}
    • {% else %}
    • {% endif %} {{ node.name }} {% for level in structure.closed_levels %}
    {% endfor %} {% endfor %}
The filter returns tuples of ``(node, structure_info)`` where ``structure_info`` contains: - ``new_level``: ``True`` if this node starts a new level, ``False`` otherwise - ``closed_levels``: List of levels that close after this node - ``ancestors``: List of ancestor node representations from root to immediate parent Example showing ancestor information: .. code-block:: html {% for node, structure in nodes|tree_info %} {{ node.name }} {% if structure.ancestors %} (Path: {% for ancestor in structure.ancestors %}{{ ancestor }}{% if not forloop.last %} > {% endif %}{% endfor %}) {% endif %} {% endfor %} recursetree tag --------------- The ``recursetree`` tag provides recursive rendering similar to django-mptt's ``recursetree`` tag, but optimized for django-tree-queries. It only considers nodes within the provided queryset and doesn't make additional database queries. Basic usage: .. code-block:: html {% load tree_queries %}
    {% recursetree nodes %}
  • {{ node.name }} {% if children %}
      {{ children }}
    {% endif %}
  • {% endrecursetree %}
The ``recursetree`` tag provides these context variables within the template: - ``node``: The current tree node - ``children``: Rendered HTML of child nodes (from the queryset) - ``is_leaf``: ``True`` if the node has no children in the queryset Using ``is_leaf`` for conditional rendering: .. code-block:: html {% recursetree nodes %}
{{ node.name }} {% if children %}
{{ children }}
{% elif is_leaf %} 🍃 {% endif %}
{% endrecursetree %} Advanced example with depth information: .. code-block:: html {% recursetree nodes %}
{{ node.name }} {% if children %}
{{ children }}
{% endif %}
{% endrecursetree %} Working with limited querysets ------------------------------- Both template tags respect queryset boundaries and work efficiently with filtered or limited querysets: .. code-block:: python # Only nodes up to depth 2 limited_nodes = Node.objects.with_tree_fields().extra( where=["__tree.tree_depth <= %s"], params=[2] ) # Only specific branches branch_nodes = Node.objects.descendants(some_node, include_self=True) When using these limited querysets: - ``recursetree`` will only render nodes from the queryset - ``is_leaf`` reflects whether nodes have children *in the queryset*, not in the full tree - No additional database queries are made - Nodes whose parents aren't in the queryset are treated as root nodes Example with depth-limited queryset: .. code-block:: html {% recursetree limited_nodes %}
  • {{ node.name }} {% if is_leaf %} (leaf in limited view) {% endif %} {{ children }}
  • {% endrecursetree %} This is particularly useful for creating expandable tree interfaces or rendering only portions of large trees for performance. django-tree-queries-0.20/docs/000077500000000000000000000000001502222354300162335ustar00rootroot00000000000000django-tree-queries-0.20/docs/Makefile000066400000000000000000000011341502222354300176720ustar00rootroot00000000000000# 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.20/docs/conf.py000066400000000000000000000024651502222354300175410ustar00rootroot00000000000000import 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.20/docs/index.rst000066400000000000000000000000711502222354300200720ustar00rootroot00000000000000.. include:: ../README.rst .. include:: ../CHANGELOG.rst django-tree-queries-0.20/docs/make.bat000066400000000000000000000013751502222354300176460ustar00rootroot00000000000000@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.20/pyproject.toml000066400000000000000000000052611502222354300202230ustar00rootroot00000000000000[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", "Programming Language :: Python :: 3.13", "Topic :: Internet :: WWW/HTTP :: Dynamic Content", "Topic :: Software Development", ] dynamic = [ "version", ] optional-dependencies.tests = [ "coverage", "pytest", "pytest-cov", "pytest-django", ] 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] target-version = "py38" preview = true fix = true show-fixes = true lint.extend-select = [ # flake8-builtins "A", # flake8-bugbear "B", # flake8-comprehensions "C4", # mmcabe "C90", # flake8-django "DJ", "E", # pyflakes, pycodestyle "F", # flake8-boolean-trap "FBT", # flake8-logging-format "G", # isort "I", # flake8-gettext "INT", # pep8-naming "N", # pygrep-hooks "PGH", # flake8-pie "PIE", # pylint "PLC", "PLE", "PLW", # flake8-pytest-style "PT", # unused noqa "RUF100", # flake8-simplify "SIM", # pyupgrade "UP", "W", # flake8-2020 "YTT", ] lint.extend-ignore = [ # Allow zip() without strict= "B905", # No line length errors "E501", ] lint.per-file-ignores."*/migrat*/*" = [ # Allow using PascalCase model names in migrations "N806", # Ignore the fact that migration files are invalid module names "N999", ] lint.isort.combine-as-imports = true lint.isort.lines-after-imports = 2 lint.mccabe.max-complexity = 15 [tool.pytest.ini_options] DJANGO_SETTINGS_MODULE = "testapp.settings" python_files = [ "tests.py", "test_*.py", "*_tests.py" ] testpaths = [ "tests" ] addopts = "-v --tb=short --strict-markers --ds=testapp.settings --cov=tree_queries --cov-report=term-missing" markers = [ "django_db: mark test to use django database", "postgresql: mark test as PostgreSQL-specific", "mysql: mark test as MySQL-specific", ] django-tree-queries-0.20/tests/000077500000000000000000000000001502222354300164455ustar00rootroot00000000000000django-tree-queries-0.20/tests/.gitignore000066400000000000000000000000241502222354300204310ustar00rootroot00000000000000/.coverage /htmlcov django-tree-queries-0.20/tests/manage.py000077500000000000000000000005351502222354300202550ustar00rootroot00000000000000#!/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.20/tests/testapp/000077500000000000000000000000001502222354300201255ustar00rootroot00000000000000django-tree-queries-0.20/tests/testapp/__init__.py000066400000000000000000000000001502222354300222240ustar00rootroot00000000000000django-tree-queries-0.20/tests/testapp/admin.py000066400000000000000000000002361502222354300215700ustar00rootroot00000000000000from django.contrib import admin from testapp import models @admin.register(models.Model) class ModelAdmin(admin.ModelAdmin): list_display = ("name",) django-tree-queries-0.20/tests/testapp/models.py000066400000000000000000000057521502222354300217730ustar00rootroot00000000000000import 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): name = models.CharField(max_length=100) 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.20/tests/testapp/settings.py000066400000000000000000000047021502222354300223420ustar00rootroot00000000000000import os DATABASES = { "default": { "ENGINE": f"django.db.backends.{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.20/tests/testapp/test_queries.py000066400000000000000000001132221502222354300232140ustar00rootroot00000000000000from types import SimpleNamespace import pytest 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 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 @pytest.mark.django_db class TestTreeQueries: 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() assert len(Model.objects.with_tree_fields()) == 1 instance = Model.objects.with_tree_fields().get() assert instance.tree_depth == 0 assert instance.tree_path == [instance.pk] def test_no_attributes(self): tree = self.create_tree() root = Model.objects.get(pk=tree.root.pk) assert not hasattr(root, "tree_depth") assert not hasattr(root, "tree_ordering") assert not 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) ) assert 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. assert child2_2.tree_ordering == [1, 5, 6] assert child2_2.tree_path == [tree.root.pk, tree.child2.pk, tree.child2_2.pk] def test_ancestors(self): tree = self.create_tree() from django.test import TestCase tc = TestCase() with tc.assertNumQueries(2): assert list(tree.child2_2.ancestors()) == [tree.root, tree.child2] assert list(tree.child2_2.ancestors(include_self=True)) == [ tree.root, tree.child2, tree.child2_2, ] assert list(tree.child2_2.ancestors().reverse()) == [tree.child2, tree.root] assert list(tree.root.ancestors()) == [] assert list(tree.root.ancestors(include_self=True)) == [tree.root] child2_2 = Model.objects.with_tree_fields().get(pk=tree.child2_2.pk) from django.test import TestCase tc = TestCase() with tc.assertNumQueries(1): assert list(child2_2.ancestors()) == [tree.root, tree.child2] def test_descendants(self): tree = self.create_tree() assert list(tree.child2.descendants()) == [tree.child2_1, tree.child2_2] assert 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() assert list(qs.filter(pk=tree.child1.pk) | qs.filter(pk=tree.child2.pk)) == [ tree.child1, tree.child2, ] def test_twice(self): assert list(Model.objects.with_tree_fields().with_tree_fields()) == [] def test_boring_coverage(self): with pytest.raises(ValueError): TreeQuery(Model).get_compiler() def test_count(self): tree = self.create_tree() assert Model.objects.count() == 6 assert Model.objects.with_tree_fields().count() == 6 assert Model.objects.with_tree_fields().distinct().count() == 6 assert list(Model.objects.descendants(tree.child1)) == [tree.child1_1] assert Model.objects.descendants(tree.child1).count() == 1 assert Model.objects.descendants(tree.child1).distinct().count() == 1 # .distinct() shouldn't always remove tree fields qs = list(Model.objects.with_tree_fields().distinct()) assert qs[0].tree_depth == 0 assert qs[5].tree_depth == 2 def test_annotate(self): tree = self.create_tree() assert [ (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) assert 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_update_descendants(self): """UpdateQuery does not work with tree queries""" tree = self.create_tree() # OperationalError would probably be appropriate, but the psycopg2 # backend raises psycopg2.errors.UndefinedTable, which isn't an # OperationalError subclass. with pytest.raises(Exception) as cm: tree.root.descendants().update(name="test") assert "__tree" in str(cm.value) def test_update_descendants_with_filter(self): """Updating works when using a filter""" tree = self.create_tree() Model.objects.filter(pk__in=tree.child2.descendants()).update(name="test") assert [node.name for node in Model.objects.with_tree_fields()] == [ "root", "1", "1-1", "2", "test", "test", ] def test_delete_descendants(self): """DeleteQuery works with tree queries""" tree = self.create_tree() tree.child2.descendants(include_self=True).delete() assert list(Model.objects.with_tree_fields()) == [ tree.root, tree.child1, tree.child1_1, # tree.child2, # tree.child2_1, # tree.child2_2, ] def test_aggregate_descendants(self): """AggregateQuery works with tree queries""" tree = self.create_tree() assert tree.root.descendants(include_self=True).aggregate(Sum("pk"))[ "pk__sum" ] == sum(node.pk for node in Model.objects.all()) def test_values(self): self.create_tree() assert 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() assert 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() assert 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() assert 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 pytest.raises(ValidationError) as cm: tree.root.full_clean() assert cm.value.messages == ["A node cannot be made a descendant of itself."] # No error. tree.child1.full_clean() def test_unordered(self): assert list(UnorderedModel.objects.all()) == [] u2 = UnorderedModel.objects.create(name="u2") u1 = UnorderedModel.objects.create(name="u1") u0 = UnorderedModel.objects.create(name="u0") u1.parent = u0 u1.save() u2.parent = u0 u2.save() # Siblings are ordered by primary key (in order of creation) assert list([ obj.name for obj in UnorderedModel.objects.with_tree_fields() ]) == ["u0", "u2", "u1"] def test_revert(self): tree = self.create_tree() obj = ( Model.objects.with_tree_fields().without_tree_fields().get(pk=tree.root.pk) ) assert not 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()}" assert f'' in html assert "root" in 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()}" assert f'' in html assert "root" not in 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 ) assert list(StringOrderedModel.objects.with_tree_fields()) == [ tree.americas, tree.north_america, tree.south_america, tree.colombia, tree.ecuador, tree.peru, tree.europe, tree.france, ] assert list(tree.peru.ancestors(include_self=True)) == [ tree.americas, tree.south_america, tree.peru, ] assert 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()] assert 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"] ) assert 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() assert hasattr(obj, "tree_depth") assert hasattr(obj, "tree_ordering") assert hasattr(obj, "tree_path") assert obj.tree_depth == 0 AlwaysTreeQueryModel.objects.update(name="Something") obj.refresh_from_db() assert 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() assert m1 == m3 assert m3.tree_depth == 0 m4 = c.instances.get() assert m1 == m4 assert 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 ) assert list( ReferenceModel.objects.filter( tree_field__in=tree.child2.descendants(include_self=True) ) ) == [references.child2, references.child2_1, references.child2_2] assert 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, ] assert 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] assert 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] assert 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] assert 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] assert 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 assert ( list(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(), ) ) assert [(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") assert set(root.descendants()) == {child1, child2} assert 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") assert list(nodes) == second_order nodes = MultiOrderedModel.objects.with_tree_fields() assert list(nodes) == first_order nodes = MultiOrderedModel.objects.order_siblings_by("second_position").all() assert 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], ) assert list(nodes) == [ tree.root, tree.child1, # tree.child1_1, tree.child2, # tree.child2_1, # tree.child2_2, ] @pytest.mark.postgresql @pytest.mark.skipif( connections["default"].vendor != "postgresql", reason="EXPLAIN test only meaningful for PostgreSQL", ) def test_explain(self): explanation = Model.objects.with_tree_fields().explain() assert "CTE" in explanation def test_tree_queries_without_tree_node(self): TreeNodeIsOptional.objects.create(parent=TreeNodeIsOptional.objects.create()) nodes = list(TreeNodeIsOptional.objects.with_tree_fields()) assert nodes[0].tree_depth == 0 assert 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() assert set([(p.name, tuple(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() assert set([(p.name, tuple(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() assert set([(p.name, tuple(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() assert set([(p.name, tuple(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") assert 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" ) assert 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") assert 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") assert 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"]) assert 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"] ) assert 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) assert 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") assert 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"]) ) assert 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 ) assert 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] assert 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] assert 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], [""]) def test_invalid_sibling_order_type(self): """Test that invalid sibling order types raise ValueError""" tree = self.create_tree() # Create a TreeQuery directly to test the validation in get_rank_table from tree_queries.compiler import TreeCompiler, TreeQuery query = TreeQuery(Model) query.sibling_order = 123 # Invalid type compiler = TreeCompiler(query, connections[Model.objects.db], Model.objects.db) # This should raise ValueError during get_rank_table with pytest.raises( ValueError, match="Sibling order must be a string or a list or tuple of strings", ): compiler.get_rank_table() @pytest.mark.postgresql @pytest.mark.skipif( connections["default"].vendor != "postgresql", reason="EXPLAIN test only meaningful for PostgreSQL", ) def test_explain_query_handling(self): """Test that EXPLAIN queries are handled correctly""" tree = self.create_tree() # This should not raise an error and should include EXPLAIN in output explanation = Model.objects.with_tree_fields().explain() assert "CTE" in explanation @pytest.mark.mysql @pytest.mark.skipif( connections["default"].vendor != "mysql", reason="MySQL-specific code path test only meaningful for MySQL", ) def test_mysql_specific_code_paths(self): """Test MySQL-specific code paths""" tree = self.create_tree() # Test that queries work with MySQL-specific string concatenation nodes = list(Model.objects.with_tree_fields()) assert len(nodes) == 6 # This exercises the MySQL-specific CTE implementation descendants = list(tree.root.descendants()) assert len(descendants) == 5 @pytest.mark.postgresql @pytest.mark.skipif( connections["default"].vendor != "postgresql", reason="PostgreSQL-specific descendants query test only meaningful for PostgreSQL", ) def test_postgresql_descendants_query_path(self): """Test PostgreSQL-specific descendants query logic""" tree = self.create_tree() # This exercises the PostgreSQL-specific path in query.py:120 using ANY() syntax descendants = list(Model.objects.descendants(tree.child2)) expected_descendants = [tree.child2_1, tree.child2_2] assert len(descendants) == 2 assert set(descendants) == set(expected_descendants) def test_rank_table_optimization(self): """Test that rank table optimization works correctly""" from tree_queries.compiler import TreeCompiler, TreeQuery # Test that simple cases can skip rank table (all databases now support it) query = TreeQuery(Model) compiler = TreeCompiler(query, connections[Model.objects.db], Model.objects.db) # Default should allow optimization assert compiler._can_skip_rank_table() # Descending order should prevent optimization query.sibling_order = "-order" assert not compiler._can_skip_rank_table() # Multiple fields should prevent optimization query.sibling_order = ["order", "name"] assert not compiler._can_skip_rank_table() # String fields should prevent optimization query.sibling_order = "name" assert not compiler._can_skip_rank_table() # Test that tree filters prevent optimization tree = self.create_tree() filtered_qs = Model.objects.tree_filter(name="root") query = filtered_qs.query compiler = TreeCompiler(query, connections[Model.objects.db], Model.objects.db) assert not compiler._can_skip_rank_table() # Test that simple custom tree fields now allow optimization custom_fields_qs = Model.objects.tree_fields(tree_names="name") query = custom_fields_qs.query compiler = TreeCompiler(query, connections[Model.objects.db], Model.objects.db) assert compiler._can_skip_rank_table() # Now should allow optimization def test_optimization_sql_differences(self): """Test that the optimization produces different SQL""" tree = self.create_tree() # Simple query that should use optimization simple_qs = Model.objects.with_tree_fields() simple_sql, _ = simple_qs.query.get_compiler(using=Model.objects.db).as_sql() # Complex query that should NOT use optimization (descending order) complex_qs = Model.objects.with_tree_fields().order_siblings_by("-order") complex_sql, _ = complex_qs.query.get_compiler(using=Model.objects.db).as_sql() # The optimized query should not contain "__rank_table" assert "__rank_table" not in simple_sql # The complex query should contain "__rank_table" assert "__rank_table" in complex_sql # Both should contain "__tree" CTE assert "__tree" in simple_sql assert "__tree" in complex_sql def test_tree_fields_optimization(self): """Test that tree fields work with the optimization""" from tree_queries.compiler import TreeCompiler tree = self.create_tree() # Test that simple tree fields use optimization qs = Model.objects.tree_fields(tree_names="name") query = qs.query compiler = TreeCompiler(query, connections[Model.objects.db], Model.objects.db) assert compiler._can_skip_rank_table() # Test that the query works correctly results = list(qs) assert len(results) == 6 # Check that tree_names field is populated correctly root = next(obj for obj in results if obj.name == "root") assert root.tree_names == ["root"] child2_2 = next(obj for obj in results if obj.name == "2-2") assert child2_2.tree_names == ["root", "2", "2-2"] django-tree-queries-0.20/tests/testapp/test_templatetags.py000066400000000000000000000676061502222354300242470ustar00rootroot00000000000000from types import SimpleNamespace import pytest from django import template from django.template import Context, Template from testapp.models import Model from tree_queries.templatetags.tree_queries import ( previous_current_next, tree_info, tree_item_iterator, ) @pytest.mark.django_db class TestTemplateTags: 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_previous_current_next_basic(self): """Test the previous_current_next utility function""" items = [1, 2, 3, 4] result = list(previous_current_next(items)) expected = [(None, 1, 2), (1, 2, 3), (2, 3, 4), (3, 4, None)] assert result == expected def test_previous_current_next_empty(self): """Test previous_current_next with empty list""" items = [] result = list(previous_current_next(items)) assert result == [] def test_previous_current_next_single(self): """Test previous_current_next with single item""" items = [42] result = list(previous_current_next(items)) assert result == [(None, 42, None)] def test_tree_item_iterator_basic(self): """Test tree_item_iterator without ancestors""" tree = self.create_tree() items = list(Model.objects.with_tree_fields()) result = list(tree_item_iterator(items)) # Check that we get the expected number of items assert len(result) == 6 # Check structure of first item (root) item, structure = result[0] assert item == tree.root assert structure["new_level"] is True assert structure["closed_levels"] == [] # Check structure of second item (child1) item, structure = result[1] assert item == tree.child1 assert structure["new_level"] is True assert structure["closed_levels"] == [] # Check structure of third item (child1_1) item, structure = result[2] assert item == tree.child1_1 assert structure["new_level"] is True assert structure["closed_levels"] == [2] # Check structure of last item (child2_2) item, structure = result[5] assert item == tree.child2_2 assert structure["new_level"] is False assert structure["closed_levels"] == [2, 1, 0] def test_tree_item_iterator_with_ancestors(self): """Test tree_item_iterator with ancestors enabled""" tree = self.create_tree() items = list(Model.objects.with_tree_fields()) result = list(tree_item_iterator(items, ancestors=True)) # Check structure of root item item, structure = result[0] assert item == tree.root assert structure["ancestors"] == [] # Check structure of child1_1 item item, structure = result[2] assert item == tree.child1_1 assert structure["ancestors"] == [str(tree.root), str(tree.child1)] # Check structure of child2_1 item item, structure = result[4] assert item == tree.child2_1 assert structure["ancestors"] == [str(tree.root), str(tree.child2)] def test_tree_item_iterator_with_custom_callback(self): """Test tree_item_iterator with custom callback for ancestors""" tree = self.create_tree() items = list(Model.objects.with_tree_fields()) # Custom callback that returns the name attribute def name_callback(obj): return obj.name result = list(tree_item_iterator(items, ancestors=True, callback=name_callback)) # Check structure of child1_1 item with custom callback item, structure = result[2] assert item == tree.child1_1 assert structure["ancestors"] == ["root", "1"] def test_tree_info_filter_basic(self): """Test the tree_info template filter basic functionality""" tree = self.create_tree() items = list(Model.objects.with_tree_fields()) result = list(tree_info(items)) # Should return same as tree_item_iterator with ancestors=True expected = list(tree_item_iterator(items, ancestors=True)) assert len(result) == len(expected) # Check that structure matches for (item1, struct1), (item2, struct2) in zip(result, expected): assert item1 == item2 assert struct1["new_level"] == struct2["new_level"] assert struct1["closed_levels"] == struct2["closed_levels"] assert struct1["ancestors"] == struct2["ancestors"] def test_tree_info_filter_always_has_ancestors(self): """Test that tree_info filter always includes ancestors""" tree = self.create_tree() items = list(Model.objects.with_tree_fields()) result = list(tree_info(items)) # Check that ancestors are always included item, structure = result[2] # child1_1 assert item == tree.child1_1 assert "ancestors" in structure assert structure["ancestors"] == [str(tree.root), str(tree.child1)] # Check root has empty ancestors item, structure = result[0] # root assert item == tree.root assert "ancestors" in structure assert structure["ancestors"] == [] def test_tree_info_in_template(self): """Test tree_info filter used in an actual Django template""" tree = self.create_tree() items = list(Model.objects.with_tree_fields()) template = Template(""" {% load tree_queries %} {% for item, structure in items|tree_info %} {% if structure.new_level %}
    • {% else %}
    • {% endif %} {{ item.name }} {% for level in structure.closed_levels %}
    {% endfor %} {% endfor %} """) context = Context({"items": items}) result = template.render(context) # Check that the template renders without errors assert "root" in result assert "1" in result assert "1-1" in result assert "2" in result assert "2-1" in result assert "2-2" in result # Check for proper nesting structure assert "
    • " in result assert "
    " in result def test_tree_info_with_ancestors_in_template(self): """Test tree_info filter with ancestors in template""" tree = self.create_tree() items = list(Model.objects.with_tree_fields()) template = Template(""" {% load tree_queries %} {% for item, structure in items|tree_info %} {{ item.name }}{% if structure.ancestors %} (ancestors: {% for ancestor in structure.ancestors %}{{ ancestor }}{% if not forloop.last %}, {% endif %}{% endfor %}){% endif %} {% endfor %} """) context = Context({"items": items}) result = template.render(context) # Check that ancestors are properly displayed assert "root" in result assert "(ancestors: root)" in result assert "(ancestors: root, 1)" in result assert "(ancestors: root, 2)" in result def test_empty_items_list(self): """Test template tags with empty items list""" result = list(tree_info([])) assert result == [] result = list(tree_item_iterator([])) assert result == [] def test_single_item_tree(self): """Test template tags with single item""" root = Model.objects.create(name="root") items = list(Model.objects.with_tree_fields()) result = list(tree_info(items)) assert len(result) == 1 item, structure = result[0] assert item == root assert structure["new_level"] is True assert structure["closed_levels"] == [0] def test_recursetree_basic(self): """Test basic recursetree functionality""" tree = self.create_tree() items = Model.objects.with_tree_fields() template = Template(""" {% load tree_queries %}
      {% recursetree items %}
    • {{ node.name }} {% if children %}
        {{ children }}
      {% endif %}
    • {% endrecursetree %}
    """) context = Context({"items": items}) result = template.render(context) # Check that all nodes are rendered assert "root" in result assert "1" in result assert "1-1" in result assert "2" in result assert "2-1" in result assert "2-2" in result # Check nested structure assert "
      " in result assert "
    • " in result assert "
    • " in result assert "
    " in result def test_recursetree_with_depth_info(self): """Test recursetree with node depth information""" tree = self.create_tree() items = Model.objects.with_tree_fields() template = Template(""" {% load tree_queries %} {% recursetree items %}
    {{ node.name }} {{ children }}
    {% endrecursetree %} """) context = Context({"items": items}) result = template.render(context) # Check depth classes are applied correctly assert 'class="depth-0"' in result # root assert 'class="depth-1"' in result # child1, child2 assert 'class="depth-2"' in result # child1_1, child2_1, child2_2 def test_recursetree_empty_queryset(self): """Test recursetree with empty queryset""" template = Template(""" {% load tree_queries %}
      {% recursetree items %}
    • {{ node.name }}
    • {% endrecursetree %}
    """) context = Context({"items": Model.objects.none()}) result = template.render(context) # Should render just the outer ul assert "
      " in result assert "
    " in result assert "
  • " not in result def test_recursetree_single_root(self): """Test recursetree with single root node""" root = Model.objects.create(name="lone-root") items = Model.objects.with_tree_fields() template = Template(""" {% load tree_queries %} {% recursetree items %} {{ node.name }}{{ children }} {% endrecursetree %} """) context = Context({"items": items}) result = template.render(context) assert "lone-root" in result assert "" in result def test_recursetree_without_tree_fields(self): """Test recursetree with queryset that doesn't have tree fields""" tree = self.create_tree() # Use regular queryset without tree fields items = Model.objects.all() template = Template(""" {% load tree_queries %} {% recursetree items %}
    {{ node.name }}{{ children }}
    {% endrecursetree %} """) context = Context({"items": items}) result = template.render(context) # Should still render root node (the one with parent_id=None) assert "root" in result def test_recursetree_conditional_children(self): """Test recursetree with conditional children rendering""" tree = self.create_tree() items = Model.objects.with_tree_fields() template = Template(""" {% load tree_queries %} {% recursetree items %}
  • {{ node.name }} {% if children %}
      {{ children }}
    {% else %} {% endif %}
  • {% endrecursetree %} """) context = Context({"items": items}) result = template.render(context) # Check that leaf nodes get the leaf class assert 'class="leaf-node"' in result # Check that parent nodes get the has-children class assert 'class="has-children"' in result def test_recursetree_complex_template(self): """Test recursetree with more complex template logic""" tree = self.create_tree() items = Model.objects.with_tree_fields() template = Template(""" {% load tree_queries %} {% recursetree items %}
    {{ node.name }} {% if children %}
    {{ children }}
    {% endif %}
    {% endrecursetree %} """) context = Context({"items": items}) result = template.render(context) # Check data attributes are present assert "data-id=" in result assert "data-depth=" in result # Check heading levels (h1 for root, h2 for level 1, etc.) assert "

    " in result # root assert "

    " in result # children assert "

    " in result # grandchildren def test_recursetree_syntax_error(self): """Test that recursetree raises proper syntax error for invalid usage""" with pytest.raises(template.TemplateSyntaxError) as excinfo: Template(""" {% load tree_queries %} {% recursetree %} {% endrecursetree %} """) assert "tag requires a queryset" in str(excinfo.value) with pytest.raises(template.TemplateSyntaxError) as excinfo: Template(""" {% load tree_queries %} {% recursetree items extra_arg %} {% endrecursetree %} """) assert "tag requires a queryset" in str(excinfo.value) def test_recursetree_limited_queryset_depth(self): """Test recursetree with queryset limited to specific depth""" tree = self.create_tree() # Only get nodes up to depth 1 (root and first level children) items = Model.objects.with_tree_fields().extra( where=["__tree.tree_depth <= %s"], params=[1] ) template = Template(""" {% load tree_queries %} {% recursetree items %}
  • {{ node.name }} {% if children %}
      {{ children }}
    {% endif %}
  • {% endrecursetree %} """) context = Context({"items": items}) result = template.render(context) # Should include root, child1, child2 but NOT child1_1, child2_1, child2_2 assert "root" in result assert "1" in result # child1 assert "2" in result # child2 assert "1-1" not in result # should not be rendered assert "2-1" not in result # should not be rendered assert "2-2" not in result # should not be rendered # Check depth attributes assert 'data-depth="0"' in result # root assert 'data-depth="1"' in result # children assert 'data-depth="2"' not in result # grandchildren excluded def test_recursetree_filtered_by_name(self): """Test recursetree with queryset filtered by specific criteria""" tree = self.create_tree() # Only get nodes with specific names (partial tree) items = Model.objects.with_tree_fields().filter( name__in=["root", "2", "2-1", "1"] # Excludes "1-1" and "2-2" ) template = Template(""" {% load tree_queries %} {% recursetree items %} {{ node.name }}{{ children }} {% endrecursetree %} """) context = Context({"items": items}) result = template.render(context) # Should include filtered nodes assert "root" in result assert ">1<" in result # child1 assert ">2<" in result # child2 assert "2-1" in result # child2_1 # Should NOT include excluded nodes assert "1-1" not in result assert "2-2" not in result def test_recursetree_subtree_only(self): """Test recursetree with queryset containing only a subtree""" tree = self.create_tree() # Only get child2 and its descendants (excludes root, child1, child1_1) items = Model.objects.descendants(tree.child2, include_self=True) template = Template(""" {% load tree_queries %} {% recursetree items %}
    {{ node.name }}{{ children }}
    {% endrecursetree %} """) context = Context({"items": items}) result = template.render(context) # Should include only child2 and its descendants assert "2" in result # child2 (root of subtree) assert "2-1" in result assert "2-2" in result # Should NOT include nodes outside the subtree assert "root" not in result assert 'data-name="1"' not in result # child1 assert "1-1" not in result def test_recursetree_orphaned_nodes(self): """Test recursetree with queryset that has orphaned nodes (parent not in queryset)""" tree = self.create_tree() # Get only leaf nodes (their parents are not included) items = Model.objects.with_tree_fields().filter(name__in=["1-1", "2-1", "2-2"]) template = Template(""" {% load tree_queries %} {% recursetree items %}
  • {{ node.name }}{{ children }}
  • {% endrecursetree %} """) context = Context({"items": items}) result = template.render(context) # All nodes should be treated as roots since their parents aren't in queryset assert "1-1" in result assert "2-1" in result assert "2-2" in result # Should render three separate root nodes assert result.count("
  • ") == 3 def test_recursetree_mixed_levels(self): """Test recursetree with queryset containing nodes from different levels""" tree = self.create_tree() # Mix of root, some children, and some grandchildren items = Model.objects.with_tree_fields().filter( name__in=["root", "1-1", "2", "2-2"] # Skip child1 and child2_1 ) template = Template(""" {% load tree_queries %} {% recursetree items %}
    {{ node.name }} {% if children %}[{{ children }}]{% endif %}
    {% endrecursetree %} """) context = Context({"items": items}) result = template.render(context) # root should be a root with child2 as its child assert 'data-name="root"' in result assert 'data-name="2"' in result # 1-1 should be orphaned (parent "1" not in queryset) assert 'data-name="1-1"' in result # 2-2 should be child of 2 assert 'data-name="2-2"' in result # Check nesting - root should contain 2, and 2 should contain 2-2 assert "root" in result and "[" in result # root has children assert "]" in result # 2 has children (contains closing bracket) def test_recursetree_no_database_queries_for_children(self): """Test that recursetree doesn't make additional database queries for children""" tree = self.create_tree() items = Model.objects.with_tree_fields() template = Template(""" {% load tree_queries %} {% recursetree items %}
    {{ node.name }}{{ children }}
    {% endrecursetree %} """) context = Context({"items": items}) # Force evaluation of queryset to count queries list(items) # Count queries during template rendering from django.test import TestCase tc = TestCase() with tc.assertNumQueries(0): # Should not make any additional queries result = template.render(context) # Verify the result still contains all expected nodes assert "root" in result assert "1" in result assert "1-1" in result assert "2" in result assert "2-1" in result assert "2-2" in result def test_recursetree_is_leaf_context_variable(self): """Test that is_leaf context variable is properly set""" tree = self.create_tree() items = Model.objects.with_tree_fields() template = Template(""" {% load tree_queries %} {% recursetree items %}
    {{ node.name }} {% if is_leaf %}[LEAF]{% endif %} {{ children }}
    {% endrecursetree %} """) context = Context({"items": items}) result = template.render(context) # Check that leaf nodes are marked as such assert 'data-name="1-1" data-is-leaf="True"' in result # child1_1 is leaf assert 'data-name="2-1" data-is-leaf="True"' in result # child2_1 is leaf assert 'data-name="2-2" data-is-leaf="True"' in result # child2_2 is leaf # Check that non-leaf nodes are marked as such assert 'data-name="root" data-is-leaf="False"' in result # root has children assert 'data-name="1" data-is-leaf="False"' in result # child1 has children assert 'data-name="2" data-is-leaf="False"' in result # child2 has children # Check that [LEAF] appears for leaf nodes assert "[LEAF]" in result # Should appear for leaf nodes assert ( result.count("[LEAF]") == 3 ) # Should appear exactly 3 times (for 1-1, 2-1, 2-2) # Check that [LEAF] doesn't appear for non-leaf nodes assert "root[LEAF]" not in result assert "1[LEAF]" not in result # This might match "1-1[LEAF]", so be specific assert ">2[LEAF]" not in result def test_recursetree_is_leaf_with_limited_queryset(self): """Test is_leaf behavior with limited queryset""" tree = self.create_tree() # Only get nodes up to depth 1 - so child1 and child2 appear as leaves items = Model.objects.with_tree_fields().extra( where=["__tree.tree_depth <= %s"], params=[1] ) template = Template(""" {% load tree_queries %} {% recursetree items %} {% if is_leaf %}LEAF:{{ node.name }}{% else %}BRANCH:{{ node.name }}{% endif %} {{ children }} {% endrecursetree %} """) context = Context({"items": items}) result = template.render(context) # In this limited queryset, child1 and child2 should appear as leaves # even though they have children in the full tree assert "LEAF:1" in result # child1 appears as leaf (no children in queryset) assert "LEAF:2" in result # child2 appears as leaf (no children in queryset) assert "BRANCH:root" in result # root has children (child1, child2) in queryset # These shouldn't appear since they're not in the queryset assert "1-1" not in result assert "2-1" not in result assert "2-2" not in result def test_recursetree_is_leaf_orphaned_nodes(self): """Test is_leaf with orphaned nodes (parent not in queryset)""" tree = self.create_tree() # Get only leaf nodes - they should all be treated as leaf nodes items = Model.objects.with_tree_fields().filter(name__in=["1-1", "2-1", "2-2"]) template = Template(""" {% load tree_queries %} {% recursetree items %}
  • {{ node.name }}
  • {% endrecursetree %} """) context = Context({"items": items}) result = template.render(context) # All nodes should be leaves since they have no children in the queryset assert 'data-leaf="True"' in result assert 'data-leaf="False"' not in result assert result.count('data-leaf="True"') == 3 # All three nodes are leaves def test_recursetree_cache_reuse(self): """Test that recursetree cache is reused properly""" tree = self.create_tree() items = Model.objects.with_tree_fields() template = Template(""" {% load tree_queries %} {% recursetree items %}
    {{ node.name }}{{ children }}
    {% endrecursetree %} """) context = Context({"items": items}) # First render should cache the children result1 = template.render(context) # Second render should reuse the cache result2 = template.render(context) assert result1 == result2 assert "root" in result1 def test_recursetree_nodes_without_tree_ordering(self): """Test recursetree with nodes that don't have tree_ordering attribute""" from testapp.models import UnorderedModel # Create tree without tree_ordering u0 = UnorderedModel.objects.create(name="u0") u1 = UnorderedModel.objects.create(name="u1", parent=u0) u2 = UnorderedModel.objects.create(name="u2", parent=u0) items = UnorderedModel.objects.with_tree_fields() template = Template(""" {% load tree_queries %} {% recursetree items %} {{ node.name }}{{ children }} {% endrecursetree %} """) context = Context({"items": items}) result = template.render(context) # Should render correctly even without tree_ordering assert "u0" in result assert "u1" in result assert "u2" in result def test_recursetree_get_children_from_cache_edge_cases(self): """Test edge cases in _get_children_from_cache method""" tree = self.create_tree() items = Model.objects.with_tree_fields() # Create a RecurseTreeNode instance from django.template import Variable from tree_queries.templatetags.tree_queries import RecurseTreeNode queryset_var = Variable("items") nodelist = [] # Empty nodelist for testing recurse_node = RecurseTreeNode(nodelist, queryset_var) # Test when cache is None assert recurse_node._get_children_from_cache(tree.root) == [] # Test when cache exists but node not in cache recurse_node._cached_children = {} assert recurse_node._get_children_from_cache(tree.root) == [] def test_tree_item_iterator_edge_cases(self): """Test edge cases in tree_item_iterator""" # Test with single item that has tree_depth attribute class MockNode: def __init__(self, name, tree_depth=0): self.name = name self.tree_depth = tree_depth # Include required attribute mock_item = MockNode("test", tree_depth=0) # This should work correctly with tree_depth result = list(tree_item_iterator([mock_item], ancestors=True)) assert len(result) == 1 item, structure = result[0] assert item == mock_item assert structure["new_level"] is True assert "ancestors" in structure def test_previous_current_next_edge_cases(self): """Test edge cases in previous_current_next function""" # Test with generator that raises StopIteration def empty_generator(): return yield # Never reached result = list(previous_current_next(empty_generator())) assert result == [] # Test with None items result = list(previous_current_next([None, None])) expected = [(None, None, None), (None, None, None)] assert result == expected django-tree-queries-0.20/tests/testapp/urls.py000066400000000000000000000002011502222354300214550ustar00rootroot00000000000000# from django.conf.urls import url # from django.contrib import admin urlpatterns = [ # url(r"^admin/", admin.site.urls) ] django-tree-queries-0.20/tox.ini000066400000000000000000000034021502222354300166150ustar00rootroot00000000000000[tox] envlist = docs py{38,39,310}-dj{32,41,42}-{sqlite,postgresql,mysql} py{310,311,312}-dj{32,41,42,50,51}-{sqlite,postgresql,mysql} py{312,313}-dj{51,52,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 dj51: Django>=5.1,<5.2 dj52: Django>=5.2,<6.0 djmain: https://github.com/django/django/archive/main.tar.gz postgresql: psycopg2-binary mysql: mysqlclient pytest pytest-django pytest-cov passenv= CI DB_BACKEND DB_NAME DB_USER DB_PASSWORD DB_HOST DB_PORT GITHUB_* SQL setenv = PYTHONPATH = {toxinidir}:{toxinidir}/tests PYTHONWARNINGS = d DJANGO_SETTINGS_MODULE = testapp.settings 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 = pytest {posargs} [testenv:py{38,39,310,311,312,313}-dj{32,41,42,50,51,52,main}-postgresql] setenv = {[testenv]setenv} DB_BACKEND = postgresql DB_PORT = {env:DB_PORT:5432} [testenv:py{38,39,310,311,312,313}-dj{32,41,42,50,51,52,main}-mysql] setenv = {[testenv]setenv} DB_BACKEND = mysql DB_PORT = {env:DB_PORT:3306} [testenv:py{38,39,310,311,312,313}-dj{32,41,42,50,51,52,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 3.13: py313 [gh-actions:env] DB_BACKEND = mysql: mysql postgresql: postgresql sqlite3: sqlite django-tree-queries-0.20/tree_queries/000077500000000000000000000000001502222354300177775ustar00rootroot00000000000000django-tree-queries-0.20/tree_queries/__init__.py000066400000000000000000000000271502222354300221070ustar00rootroot00000000000000__version__ = "0.20.0" django-tree-queries-0.20/tree_queries/compiler.py000066400000000000000000000477241502222354300222010ustar00rootroot00000000000000import 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 ) """ # Optimized CTEs without rank table for simple cases CTE_POSTGRESQL_SIMPLE = """ WITH RECURSIVE __tree ( {tree_fields_names}"tree_depth", "tree_path", "tree_ordering", "tree_pk" ) AS ( SELECT {tree_fields_initial}0, array[T.{pk}], array[T."{order_field}"], T.{pk} FROM {db_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."{order_field}", T.{pk} FROM {db_table} T JOIN __tree ON T."{parent}" = __tree.tree_pk ) """ CTE_MYSQL_SIMPLE = """ WITH RECURSIVE __tree( {tree_fields_names}tree_depth, tree_path, tree_ordering, tree_pk ) AS ( SELECT {tree_fields_initial}0, CAST(CONCAT("{sep}", T.{pk}, "{sep}") AS char(1000)), CAST(CONCAT("{sep}", LPAD(CONCAT(T.`{order_field}`, "{sep}"), 20, "0")) AS char(1000)), T.{pk} FROM {db_table} T WHERE T.`{parent}` IS NULL UNION ALL SELECT {tree_fields_recursive}__tree.tree_depth + 1, CONCAT(__tree.tree_path, T.{pk}, "{sep}"), CONCAT(__tree.tree_ordering, LPAD(CONCAT(T.`{order_field}`, "{sep}"), 20, "0")), T.{pk} FROM {db_table} T, __tree WHERE __tree.tree_pk = T.`{parent}` ) """ CTE_SQLITE_SIMPLE = """ WITH RECURSIVE __tree( {tree_fields_names}tree_depth, tree_path, tree_ordering, tree_pk ) AS ( SELECT {tree_fields_initial}0, "{sep}" || T."{pk}" || "{sep}", "{sep}" || printf("%%020s", T."{order_field}") || "{sep}", T."{pk}" FROM {db_table} T WHERE T."{parent}" IS NULL UNION ALL SELECT {tree_fields_recursive}__tree.tree_depth + 1, __tree.tree_path || T."{pk}" || "{sep}", __tree.tree_ordering || printf("%%020s", T."{order_field}") || "{sep}", T."{pk}" FROM {db_table} T JOIN __tree ON T."{parent}" = __tree.tree_pk ) """ def _can_skip_rank_table(self): """ Determine if we can skip the rank table optimization. We can skip it when: 1. No tree filters are applied (rank_table_query is unchanged) 2. Simple ordering (single field, ascending) 3. No custom tree fields """ # Check if tree filters have been applied original_query = QuerySet(model=_find_tree_model(self.query.model)) if str(self.query.get_rank_table_query().query) != str(original_query.query): return False # Check if custom tree fields are simple column references tree_fields = self.query.get_tree_fields() if tree_fields: model = _find_tree_model(self.query.model) for name, column in tree_fields.items(): # Only allow simple column names (no complex expressions) if not isinstance(column, str): return False # Check if it's a valid field on the model try: model._meta.get_field(column) except FieldDoesNotExist: return False # Check for complex ordering sibling_order = self.query.get_sibling_order() if isinstance(sibling_order, (list, tuple)): if len(sibling_order) > 1: return False order_field = sibling_order[0] else: order_field = sibling_order # Check for descending order or complex expressions if ( isinstance(order_field, str) and order_field.startswith("-") or not isinstance(order_field, str) ): return False # Check for related field lookups (contains __) if "__" in order_field: return False # Check if the ordering field is numeric/integer # For string fields, the optimization might not preserve correct order # because we bypass the ROW_NUMBER() ranking that the complex CTE uses field = _find_tree_model(self.query.model)._meta.get_field(order_field) if not hasattr(field, "get_internal_type"): return False field_type = field.get_internal_type() if field_type not in ( "AutoField", "BigAutoField", "IntegerField", "BigIntegerField", "PositiveIntegerField", "PositiveSmallIntegerField", "SmallIntegerField", ): return False return True 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, } # Check if we can use the optimized path without rank table use_rank_table = not self._can_skip_rank_table() if use_rank_table: # Get the rank_table SQL and params rank_table_sql, rank_table_params = self.get_rank_table() params["rank_table"] = rank_table_sql else: # Use optimized path - get the order field for simple CTE sibling_order = self.query.get_sibling_order() if isinstance(sibling_order, (list, tuple)): order_field = sibling_order[0] else: order_field = sibling_order params["order_field"] = order_field rank_table_params = [] # Set database-specific CTE template and column reference format if self.connection.vendor == "postgresql": cte = ( self.CTE_POSTGRESQL_SIMPLE if not use_rank_table else self.CTE_POSTGRESQL ) cte_initial = "array[{column}]::text[], " cte_recursive = "__tree.{name} || {column}::text, " elif self.connection.vendor == "sqlite": cte = self.CTE_SQLITE_SIMPLE if not use_rank_table else self.CTE_SQLITE cte_initial = 'printf("{sep}%%s{sep}", {column}), ' cte_recursive = '__tree.{name} || printf("%%s{sep}", {column}), ' elif self.connection.vendor == "mysql": cte = self.CTE_MYSQL_SIMPLE if not use_rank_table else self.CTE_MYSQL cte_initial = 'CAST(CONCAT("{sep}", {column}, "{sep}") AS char(1000)), ' cte_recursive = 'CONCAT(__tree.{name}, {column}, "{sep}"), ' tree_fields = self.query.get_tree_fields() qn = self.connection.ops.quote_name # Generate tree field parameters using unified templates # Set column reference format based on CTE type if use_rank_table: # Complex CTE uses rank table references column_ref_format = "{column}" params.update({ "tree_fields_columns": "".join( f"{qn(column)}, " for column in tree_fields.values() ), }) else: # Simple CTE uses direct table references column_ref_format = "T.{column}" # Generate unified tree field parameters params.update({ "tree_fields_names": "".join(f"{qn(name)}, " for name in tree_fields), "tree_fields_initial": "".join( cte_initial.format( column=column_ref_format.format(column=qn(column)), name=qn(name), sep=SEPARATOR, ) for name, column in tree_fields.items() ), "tree_fields_recursive": "".join( cte_recursive.format( column=column_ref_format.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", } # Add custom tree fields for both simple and complex CTEs 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.20/tree_queries/fields.py000066400000000000000000000006441502222354300216230ustar00rootroot00000000000000from 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.20/tree_queries/forms.py000066400000000000000000000013201502222354300214730ustar00rootroot00000000000000from 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.20/tree_queries/locale/000077500000000000000000000000001502222354300212365ustar00rootroot00000000000000django-tree-queries-0.20/tree_queries/locale/de/000077500000000000000000000000001502222354300216265ustar00rootroot00000000000000django-tree-queries-0.20/tree_queries/locale/de/LC_MESSAGES/000077500000000000000000000000001502222354300234135ustar00rootroot00000000000000django-tree-queries-0.20/tree_queries/locale/de/LC_MESSAGES/django.mo000066400000000000000000000010411502222354300252060ustar00rootroot000000000000004L`-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.20/tree_queries/locale/de/LC_MESSAGES/django.po000066400000000000000000000014641502222354300252220ustar00rootroot00000000000000# 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.20/tree_queries/models.py000066400000000000000000000031401502222354300216320ustar00rootroot00000000000000from 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.20/tree_queries/query.py000066400000000000000000000110311502222354300215120ustar00rootroot00000000000000from 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 django-tree-queries-0.20/tree_queries/templatetags/000077500000000000000000000000001502222354300224715ustar00rootroot00000000000000django-tree-queries-0.20/tree_queries/templatetags/__init__.py000066400000000000000000000000001502222354300245700ustar00rootroot00000000000000django-tree-queries-0.20/tree_queries/templatetags/tree_queries.py000066400000000000000000000236551502222354300255520ustar00rootroot00000000000000# From https://raw.githubusercontent.com/triopter/django-tree-query-template/refs/heads/main/tq_template/templatetags/tq_template.py import copy import itertools from django import template from django.utils.safestring import mark_safe register = template.Library() def previous_current_next(items): """ From http://www.wordaligned.org/articles/zippy-triples-served-with-python Creates an iterator which returns (previous, current, next) triples, with ``None`` filling in when there is no previous or next available. """ extend = itertools.chain([None], items, [None]) prev, cur, nex = itertools.tee(extend, 3) # Advancing an iterator twice when we know there are two items (the # two Nones at the start and at the end) will never fail except if # `items` is some funny StopIteration-raising generator. There's no point # in swallowing this exception. next(cur) next(nex) next(nex) return zip(prev, cur, nex) def tree_item_iterator(items, *, ancestors=False, callback=str): """ Given a list of tree items, iterates over the list, generating two-tuples of the current tree item and a ``dict`` containing information about the tree structure around the item, with the following keys: ``'new_level'`` ``True`` if the current item is the start of a new level in the tree, ``False`` otherwise. ``'closed_levels'`` A list of levels which end after the current item. This will be an empty list if the next item is at the same level as the current item. If ``ancestors`` is ``True``, the following key will also be available: ``'ancestors'`` A list of representations of the ancestors of the current node, in descending order (root node first, immediate parent last). For example: given the sample tree below, the contents of the list which would be available under the ``'ancestors'`` key are given on the right:: Books -> [] Sci-fi -> ['Books'] Dystopian Futures -> ['Books', 'Sci-fi'] You can overload the default representation by providing an optional ``callback`` function which takes a single argument and performs coersion as required. """ structure = {} first_item_level = 0 for previous, current, next_ in previous_current_next(items): current_level = current.tree_depth if previous: structure["new_level"] = previous.tree_depth < current_level if ancestors: # If the previous node was the end of any number of # levels, remove the appropriate number of ancestors # from the list. if structure["closed_levels"]: structure["ancestors"] = structure["ancestors"][ : -len(structure["closed_levels"]) ] # If the current node is the start of a new level, add its # parent to the ancestors list. if structure["new_level"]: structure["ancestors"].append(callback(previous)) else: structure["new_level"] = True if ancestors: # Set up the ancestors list on the first item structure["ancestors"] = [] first_item_level = current_level if next_: structure["closed_levels"] = list( range(current_level, next_.tree_depth, -1) ) else: # All remaining levels need to be closed structure["closed_levels"] = list( range(current_level, first_item_level - 1, -1) ) # Return a deep copy of the structure dict so this function can # be used in situations where the iterator is consumed # immediately. yield current, copy.deepcopy(structure) @register.filter def tree_info(items): """ Given a list of tree items, produces doubles of a tree item and a ``dict`` containing information about the tree structure around the item, with the following contents: new_level ``True`` if the current item is the start of a new level in the tree, ``False`` otherwise. closed_levels A list of levels which end after the current item. This will be an empty list if the next item is at the same level as the current item. ancestors A list of ancestors of the current node, in descending order (root node first, immediate parent last). Using this filter with unpacking in a ``{% for %}`` tag, you should have enough information about the tree structure to create a hierarchical representation of the tree. Example:: {% for genre,structure in genres|tree_info %} {% if structure.new_level %}
    • {% else %}
    • {% endif %} {{ genre.name }} {% for level in structure.closed_levels %}
    {% endfor %} {% endfor %} """ return tree_item_iterator(items, ancestors=True) class RecurseTreeNode(template.Node): """ Template node for recursive tree rendering, similar to django-mptt's recursetree. Renders a section of template recursively for each node in a tree, providing 'node' and 'children' context variables. Only considers nodes from the provided queryset - will not fetch additional children beyond what's in the queryset. """ def __init__(self, nodelist, queryset_var): self.nodelist = nodelist self.queryset_var = queryset_var self._cached_children = None def _cache_tree_children(self, queryset): """ Cache children relationships for all nodes in the queryset. This avoids additional database queries and respects the queryset boundaries. """ if self._cached_children is not None: return self._cached_children self._cached_children = {} # Group nodes by their parent_id for efficient lookup for node in queryset: parent_id = getattr(node, "parent_id", None) if parent_id not in self._cached_children: self._cached_children[parent_id] = [] self._cached_children[parent_id].append(node) # Sort children by tree_ordering if available, otherwise by pk for children_list in self._cached_children.values(): if children_list and hasattr(children_list[0], "tree_ordering"): children_list.sort(key=lambda x: (x.tree_ordering, x.pk)) else: children_list.sort(key=lambda x: x.pk) return self._cached_children def _get_children_from_cache(self, node): """Get children of a node from the cached children, not from database""" if self._cached_children is None: return [] return self._cached_children.get(node.pk, []) def _render_node(self, context, node): """Recursively render a node and its children from the cached queryset""" bits = [] context.push() # Get children from cache (only nodes that were in the original queryset) children = self._get_children_from_cache(node) for child in children: bits.append(self._render_node(context, child)) # Set context variables that templates can access context["node"] = node context["children"] = mark_safe("".join(bits)) context["is_leaf"] = len(children) == 0 # Render the template with the current node context rendered = self.nodelist.render(context) context.pop() return rendered def render(self, context): """Render the complete tree starting from root nodes in the queryset""" queryset = self.queryset_var.resolve(context) # Ensure we have tree fields for proper traversal if hasattr(queryset, "with_tree_fields"): queryset = queryset.with_tree_fields() # Convert to list to avoid re-evaluation and cache the children relationships queryset_list = list(queryset) self._cache_tree_children(queryset_list) # Get root nodes (nodes without parents or whose parents are not in the queryset) queryset_pks = {node.pk for node in queryset_list} roots = [] for node in queryset_list: parent_id = getattr(node, "parent_id", None) if parent_id is None or parent_id not in queryset_pks: roots.append(node) # Sort roots by tree_ordering if available, otherwise by pk if roots and hasattr(roots[0], "tree_ordering"): roots.sort(key=lambda x: (x.tree_ordering, x.pk)) else: roots.sort(key=lambda x: x.pk) # Render each root node and its descendants bits = [self._render_node(context, node) for node in roots] return "".join(bits) @register.tag def recursetree(parser, token): """ Recursively render a tree structure. Usage: {% recursetree nodes %}
  • {{ node.name }} {% if children %}
      {{ children }}
    {% elif is_leaf %} Leaf node {% endif %}
  • {% endrecursetree %} This tag will render the template content for each node in the tree, providing these variables in the template context: - 'node': the current tree node - 'children': rendered HTML of all child nodes in the queryset - 'is_leaf': True if the node has no children in the queryset, False otherwise """ bits = token.contents.split() if len(bits) != 2: raise template.TemplateSyntaxError(f"{bits[0]} tag requires a queryset") queryset_var = template.Variable(bits[1]) nodelist = parser.parse(("endrecursetree",)) parser.delete_first_token() return RecurseTreeNode(nodelist, queryset_var)