diff -pruN 0.19-1/.github/workflows/test.yml 0.20-1/.github/workflows/test.yml
--- 0.19-1/.github/workflows/test.yml	2024-04-25 14:22:26.000000000 +0000
+++ 0.20-1/.github/workflows/test.yml	2025-06-11 06:51:15.000000000 +0000
@@ -13,7 +13,7 @@ jobs:
       fail-fast: false
       max-parallel: 5
       matrix:
-        python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
+        python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]
 
     services:
       mariadb:
@@ -29,23 +29,23 @@ jobs:
           - 3306:3306
 
     steps:
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v4
 
       - name: Set up Python ${{ matrix.python-version }}
-        uses: actions/setup-python@v2
+        uses: actions/setup-python@v5
         with:
           python-version: ${{ matrix.python-version }}
 
       - name: Get pip cache dir
         id: pip-cache
         run: |
-          echo "::set-output name=dir::$(pip cache dir)"
+          echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT
 
       - name: Cache
-        uses: actions/cache@v2
+        uses: actions/cache@v4
         with:
           path: ${{ steps.pip-cache.outputs.dir }}
-          key: ${{ matrix.python-version }}-v1-${{ hashFiles('**/setup.cfg') }}-${{ hashFiles('**/tox.ini') }}
+          key: ${{ matrix.python-version }}-v1-${{ hashFiles('**/pyproject.toml') }}-${{ hashFiles('**/tox.ini') }}
           restore-keys: |
             ${{ matrix.python-version }}-v1-
 
@@ -69,7 +69,7 @@ jobs:
       fail-fast: false
       max-parallel: 5
       matrix:
-        python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
+        python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]
 
     services:
       postgres:
@@ -87,23 +87,23 @@ jobs:
           --health-retries 5
 
     steps:
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v4
 
       - name: Set up Python ${{ matrix.python-version }}
-        uses: actions/setup-python@v2
+        uses: actions/setup-python@v5
         with:
           python-version: ${{ matrix.python-version }}
 
       - name: Get pip cache dir
         id: pip-cache
         run: |
-          echo "::set-output name=dir::$(pip cache dir)"
+          echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT
 
       - name: Cache
-        uses: actions/cache@v2
+        uses: actions/cache@v4
         with:
           path: ${{ steps.pip-cache.outputs.dir }}
-          key: ${{ matrix.python-version }}-v1-${{ hashFiles('**/setup.cfg') }}-${{ hashFiles('**/tox.ini') }}
+          key: ${{ matrix.python-version }}-v1-${{ hashFiles('**/pyproject.toml') }}-${{ hashFiles('**/tox.ini') }}
           restore-keys: |
             ${{ matrix.python-version }}-v1-
 
@@ -125,26 +125,26 @@ jobs:
       fail-fast: false
       max-parallel: 5
       matrix:
-        python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
+        python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]
 
     steps:
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v4
 
       - name: Set up Python ${{ matrix.python-version }}
-        uses: actions/setup-python@v2
+        uses: actions/setup-python@v5
         with:
           python-version: ${{ matrix.python-version }}
 
       - name: Get pip cache dir
         id: pip-cache
         run: |
-          echo "::set-output name=dir::$(pip cache dir)"
+          echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT
 
       - name: Cache
-        uses: actions/cache@v2
+        uses: actions/cache@v4
         with:
           path: ${{ steps.pip-cache.outputs.dir }}
-          key: ${{ matrix.python-version }}-v1-${{ hashFiles('**/setup.cfg') }}-${{ hashFiles('**/tox.ini') }}
+          key: ${{ matrix.python-version }}-v1-${{ hashFiles('**/pyproject.toml') }}-${{ hashFiles('**/tox.ini') }}
           restore-keys: |
             ${{ matrix.python-version }}-v1-
 
diff -pruN 0.19-1/.pre-commit-config.yaml 0.20-1/.pre-commit-config.yaml
--- 0.19-1/.pre-commit-config.yaml	2024-04-25 14:22:26.000000000 +0000
+++ 0.20-1/.pre-commit-config.yaml	2025-06-11 06:51:15.000000000 +0000
@@ -1,7 +1,7 @@
 exclude: ".yarn/|yarn.lock|\\.min\\.(css|js)$"
 repos:
   - repo: https://github.com/pre-commit/pre-commit-hooks
-    rev: v4.6.0
+    rev: v5.0.0
     hooks:
       - id: check-added-large-files
       - id: check-builtin-literals
@@ -14,7 +14,7 @@ repos:
       - id: mixed-line-ending
       - id: trailing-whitespace
   - repo: https://github.com/adamchainz/django-upgrade
-    rev: 1.16.0
+    rev: 1.23.1
     hooks:
       - id: django-upgrade
         args: [--target-version, "3.2"]
@@ -23,21 +23,23 @@ repos:
     hooks:
       - id: absolufy-imports
   - repo: https://github.com/astral-sh/ruff-pre-commit
-    rev: "v0.4.1"
+    rev: "v0.9.6"
     hooks:
       - id: ruff
       - id: ruff-format
-  - repo: https://github.com/pre-commit/mirrors-prettier
-    rev: v3.1.0
-    hooks:
-      - id: prettier
-        args: [--list-different, --no-semi]
-        exclude: "^conf/|.*\\.html$"
   - repo: https://github.com/tox-dev/pyproject-fmt
-    rev: 1.8.0
+    rev: v2.5.0
     hooks:
       - id: pyproject-fmt
   - repo: https://github.com/abravalheri/validate-pyproject
-    rev: v0.16
+    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
diff -pruN 0.19-1/.readthedocs.yaml 0.20-1/.readthedocs.yaml
--- 0.19-1/.readthedocs.yaml	1970-01-01 00:00:00.000000000 +0000
+++ 0.20-1/.readthedocs.yaml	2025-06-11 06:51:15.000000000 +0000
@@ -0,0 +1,17 @@
+# 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: .
diff -pruN 0.19-1/CHANGELOG.rst 0.20-1/CHANGELOG.rst
--- 0.19-1/CHANGELOG.rst	2024-04-25 14:22:26.000000000 +0000
+++ 0.20-1/CHANGELOG.rst	2025-06-11 06:51:15.000000000 +0000
@@ -4,6 +4,22 @@ Change 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)
 ~~~~~~~~~~~~~~~~~
 
diff -pruN 0.19-1/README.rst 0.20-1/README.rst
--- 0.19-1/README.rst	2024-04-25 14:22:26.000000000 +0000
+++ 0.20-1/README.rst	2025-06-11 06:51:15.000000000 +0000
@@ -42,6 +42,10 @@ Features and limitations
 - 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 <https://406.ch/writing/django-tree-queries/>`_.
@@ -63,6 +67,10 @@ Usage
   ``order_by()`` method isn't supported -- nodes are returned according to the
   `depth-first search algorithm
   <https://en.wikipedia.org/wiki/Depth-first_search>`__.
+- 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.
@@ -170,6 +178,59 @@ Basic usage
     # 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
 --------------------
@@ -197,6 +258,42 @@ If you only want nodes from the top two
     )
 
 
+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
 ~~~~~~~~~~~
 
@@ -206,3 +303,168 @@ structure is visualized using dashes etc
 ``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 %}
+    <ul>
+    {% for node, structure in nodes|tree_info %}
+        {% if structure.new_level %}<ul><li>{% else %}</li><li>{% endif %}
+        {{ node.name }}
+        {% for level in structure.closed_levels %}</li></ul>{% endfor %}
+    {% endfor %}
+    </ul>
+
+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 %}
+    <ul>
+    {% recursetree nodes %}
+        <li>
+            {{ node.name }}
+            {% if children %}
+                <ul>{{ children }}</ul>
+            {% endif %}
+        </li>
+    {% endrecursetree %}
+    </ul>
+
+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 %}
+        <div class="{% if is_leaf %}leaf-node{% else %}branch-node{% endif %}">
+            <span class="node-name">{{ node.name }}</span>
+            {% if children %}
+                <div class="children">{{ children }}</div>
+            {% elif is_leaf %}
+                <span class="leaf-indicator">🍃</span>
+            {% endif %}
+        </div>
+    {% endrecursetree %}
+
+Advanced example with depth information:
+
+.. code-block:: html
+
+    {% recursetree nodes %}
+        <div class="node depth-{{ node.tree_depth }}"
+             data-id="{{ node.pk }}"
+             data-has-children="{{ children|yesno:'true,false' }}">
+            <h{{ node.tree_depth|add:1 }}>{{ node.name }}</h{{ node.tree_depth|add:1 }}>
+            {% if children %}
+                <div class="node-children">{{ children }}</div>
+            {% endif %}
+        </div>
+    {% 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
+
+    <!-- Template -->
+    {% recursetree limited_nodes %}
+        <li>
+            {{ node.name }}
+            {% if is_leaf %}
+                <small>(leaf in limited view)</small>
+            {% endif %}
+            {{ children }}
+        </li>
+    {% endrecursetree %}
+
+This is particularly useful for creating expandable tree interfaces or
+rendering only portions of large trees for performance.
diff -pruN 0.19-1/debian/changelog 0.20-1/debian/changelog
--- 0.19-1/debian/changelog	2024-07-18 21:05:59.000000000 +0000
+++ 0.20-1/debian/changelog	2025-08-21 13:17:32.000000000 +0000
@@ -1,3 +1,13 @@
+python-django-tree-queries (0.20-1) unstable; urgency=low
+
+  * New upstream version 0.20
+  * Refresh patches.
+  * Bump Standards-Version to 4.7.2.
+  * Update year in d/copyright.
+  * Add python3-pytest-cov to Build-Depends, required by tests.
+
+ -- Michael Fladischer <fladi@debian.org>  Thu, 21 Aug 2025 13:17:32 +0000
+
 python-django-tree-queries (0.19-1) unstable; urgency=low
 
   * New upstream version 0.19
diff -pruN 0.19-1/debian/control 0.20-1/debian/control
--- 0.19-1/debian/control	2024-07-18 21:05:59.000000000 +0000
+++ 0.20-1/debian/control	2025-08-21 13:17:32.000000000 +0000
@@ -13,10 +13,11 @@ Build-Depends:
  python3-django,
  python3-hatchling,
  python3-pytest,
+ python3-pytest-cov,
  python3-pytest-django,
  python3-setuptools,
  python3-sphinx,
-Standards-Version: 4.7.0
+Standards-Version: 4.7.2
 Homepage: https://github.com/feincms/django-tree-queries
 Vcs-Browser: https://salsa.debian.org/python-team/packages/python-django-tree-queries
 Vcs-Git: https://salsa.debian.org/python-team/packages/python-django-tree-queries.git
diff -pruN 0.19-1/debian/copyright 0.20-1/debian/copyright
--- 0.19-1/debian/copyright	2024-07-18 21:05:59.000000000 +0000
+++ 0.20-1/debian/copyright	2025-08-21 13:17:32.000000000 +0000
@@ -8,7 +8,7 @@ Copyright: 2018, Feinheit AG and individ
 License: BSD-3-clause
 
 Files: debian/*
-Copyright: 2023-2024, Michael Fladischer <fladi@debian.org>
+Copyright: 2023-2025, Michael Fladischer <fladi@debian.org>
 License: BSD-3-clause
 
 License: BSD-3-clause
diff -pruN 0.19-1/debian/patches/0002-Remove-remote-badges-to-prevent-privacy-issues.patch 0.20-1/debian/patches/0002-Remove-remote-badges-to-prevent-privacy-issues.patch
--- 0.19-1/debian/patches/0002-Remove-remote-badges-to-prevent-privacy-issues.patch	2024-07-18 21:05:59.000000000 +0000
+++ 0.20-1/debian/patches/0002-Remove-remote-badges-to-prevent-privacy-issues.patch	2025-08-21 13:17:32.000000000 +0000
@@ -7,7 +7,7 @@ Subject: Remove remote badges to prevent
  1 file changed, 4 deletions(-)
 
 diff --git a/README.rst b/README.rst
-index ff2615e..504af1e 100644
+index 70a8586..3b9931f 100644
 --- a/README.rst
 +++ b/README.rst
 @@ -2,10 +2,6 @@
diff -pruN 0.19-1/debian/patches/0003-Only-install-main-package.patch 0.20-1/debian/patches/0003-Only-install-main-package.patch
--- 0.19-1/debian/patches/0003-Only-install-main-package.patch	2024-07-18 21:05:59.000000000 +0000
+++ 0.20-1/debian/patches/0003-Only-install-main-package.patch	2025-08-21 13:17:32.000000000 +0000
@@ -7,12 +7,12 @@ Subject: Only install main package.
  1 file changed, 3 insertions(+)
 
 diff --git a/pyproject.toml b/pyproject.toml
-index a5d782d..17fabec 100644
+index 09121d7..55e7fdb 100644
 --- a/pyproject.toml
 +++ b/pyproject.toml
-@@ -42,6 +42,9 @@ Homepage = "https://github.com/matthiask/django-tree-queries/"
- [tool.hatch.build]
- include = ["tree_queries/"]
+@@ -46,6 +46,9 @@ include = [
+   "tree_queries/",
+ ]
  
 +[tool.hatch.build.targets.wheel]
 +packages = ["tree_queries"]
diff -pruN 0.19-1/pyproject.toml 0.20-1/pyproject.toml
--- 0.19-1/pyproject.toml	2024-04-25 14:22:26.000000000 +0000
+++ 0.20-1/pyproject.toml	2025-06-11 06:51:15.000000000 +0000
@@ -8,9 +8,9 @@ requires = [
 name = "django-tree-queries"
 description = "Tree queries with explicit opt-in, without configurability"
 readme = "README.rst"
-license = {text = "BSD-3-Clause"}
+license = { text = "BSD-3-Clause" }
 authors = [
-    { name = "Matthias Kestenholz", email = "mk@feinheit.ch" },
+  { name = "Matthias Kestenholz", email = "mk@feinheit.ch" },
 ]
 requires-python = ">=3.8"
 classifiers = [
@@ -26,88 +26,102 @@ classifiers = [
   "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",
 ]
-[project.optional-dependencies]
-tests = [
+optional-dependencies.tests = [
   "coverage",
+  "pytest",
+  "pytest-cov",
+  "pytest-django",
 ]
-[project.urls]
-Homepage = "https://github.com/matthiask/django-tree-queries/"
+urls.Homepage = "https://github.com/matthiask/django-tree-queries/"
 
 [tool.hatch.build]
-include = ["tree_queries/"]
+include = [
+  "tree_queries/",
+]
 
 [tool.hatch.version]
 path = "tree_queries/__init__.py"
 
 [tool.ruff]
-fix = true
-preview = true
-show-fixes = true
 target-version = "py38"
 
-[tool.ruff.lint]
-extend-select = [
-  # pyflakes, pycodestyle
-  "F", "E", "W",
-  # mmcabe
-  "C90",
-  # isort
-  "I",
-  # pep8-naming
-  "N",
-  # pyupgrade
-  "UP",
-  # flake8-2020
-  "YTT",
-  # flake8-boolean-trap
-  "FBT",
-  # flake8-bugbear
-  "B",
+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",
-  # flake8-pie
-  "PIE",
-  # flake8-simplify
-  "SIM",
+  # isort
+  "I",
   # flake8-gettext
   "INT",
+  # pep8-naming
+  "N",
   # pygrep-hooks
   "PGH",
+  # flake8-pie
+  "PIE",
   # pylint
-  "PLC", "PLE", "PLW",
+  "PLC",
+  "PLE",
+  "PLW",
+  # flake8-pytest-style
+  "PT",
   # unused noqa
   "RUF100",
+  # flake8-simplify
+  "SIM",
+  # pyupgrade
+  "UP",
+  "W",
+  # flake8-2020
+  "YTT",
 ]
-extend-ignore = [
+lint.extend-ignore = [
   # Allow zip() without strict=
   "B905",
   # No line length errors
   "E501",
 ]
-
-[tool.ruff.lint.isort]
-combine-as-imports = true
-lines-after-imports = 2
-
-[tool.ruff.lint.mccabe]
-max-complexity = 15
-
-[tool.ruff.lint.per-file-ignores]
-"*/migrat*/*" = [
+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",
+]
diff -pruN 0.19-1/tests/testapp/models.py 0.20-1/tests/testapp/models.py
--- 0.19-1/tests/testapp/models.py	2024-04-25 14:22:26.000000000 +0000
+++ 0.20-1/tests/testapp/models.py	2025-06-11 06:51:15.000000000 +0000
@@ -19,7 +19,7 @@ class Model(TreeNode):
 
 
 class UnorderedModel(TreeNode):
-    pass
+    name = models.CharField(max_length=100)
 
 
 class StringOrderedModel(TreeNode):
diff -pruN 0.19-1/tests/testapp/settings.py 0.20-1/tests/testapp/settings.py
--- 0.19-1/tests/testapp/settings.py	2024-04-25 14:22:26.000000000 +0000
+++ 0.20-1/tests/testapp/settings.py	2025-06-11 06:51:15.000000000 +0000
@@ -3,7 +3,7 @@ import os
 
 DATABASES = {
     "default": {
-        "ENGINE": "django.db.backends.%s" % os.getenv("DB_BACKEND", "sqlite3"),
+        "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"),
@@ -29,7 +29,7 @@ INSTALLED_APPS = [
     # "django.contrib.staticfiles",
     # "django.contrib.messages",
     "testapp",
-    # "tree_queries",
+    "tree_queries",
 ]
 
 USE_TZ = True
diff -pruN 0.19-1/tests/testapp/test_queries.py 0.20-1/tests/testapp/test_queries.py
--- 0.19-1/tests/testapp/test_queries.py	2024-04-25 14:22:26.000000000 +0000
+++ 0.20-1/tests/testapp/test_queries.py	2025-06-11 06:51:15.000000000 +0000
@@ -1,11 +1,11 @@
 from 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 django.test import TestCase, override_settings
 
 from testapp.models import (
     AlwaysTreeQueryModel,
@@ -28,8 +28,8 @@ from tree_queries.compiler import SEPARA
 from tree_queries.query import pk
 
 
-@override_settings(DEBUG=True)
-class Test(TestCase):
+@pytest.mark.django_db
+class TestTreeQueries:
     def create_tree(self):
         tree = SimpleNamespace()
         tree.root = Model.objects.create(name="root")
@@ -43,19 +43,19 @@ class Test(TestCase):
     def test_stuff(self):
         Model.objects.create()
 
-        self.assertEqual(len(Model.objects.with_tree_fields()), 1)
+        assert len(Model.objects.with_tree_fields()) == 1
 
         instance = Model.objects.with_tree_fields().get()
-        self.assertEqual(instance.tree_depth, 0)
-        self.assertEqual(instance.tree_path, [instance.pk])
+        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)
-        self.assertFalse(hasattr(root, "tree_depth"))
-        self.assertFalse(hasattr(root, "tree_ordering"))
-        self.assertFalse(hasattr(root, "tree_path"))
+        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()
@@ -65,171 +65,215 @@ class Test(TestCase):
             .order_siblings_by("order", "pk")
             .get(pk=tree.child2_2.pk)
         )
-        self.assertEqual(child2_2.tree_depth, 2)
+        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.
-        self.assertEqual(child2_2.tree_ordering, [1, 5, 6])
-        self.assertEqual(
-            child2_2.tree_path, [tree.root.pk, tree.child2.pk, tree.child2_2.pk]
-        )
+        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()
-        with self.assertNumQueries(2):
-            self.assertEqual(list(tree.child2_2.ancestors()), [tree.root, tree.child2])
-        self.assertEqual(
-            list(tree.child2_2.ancestors(include_self=True)),
-            [tree.root, tree.child2, tree.child2_2],
-        )
-        self.assertEqual(
-            list(tree.child2_2.ancestors().reverse()), [tree.child2, tree.root]
-        )
+        from django.test import TestCase
 
-        self.assertEqual(list(tree.root.ancestors()), [])
-        self.assertEqual(list(tree.root.ancestors(include_self=True)), [tree.root])
+        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)
-        with self.assertNumQueries(1):
-            self.assertEqual(list(child2_2.ancestors()), [tree.root, tree.child2])
+        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()
-        self.assertEqual(
-            list(tree.child2.descendants()), [tree.child2_1, tree.child2_2]
-        )
-        self.assertEqual(
-            list(tree.child2.descendants(include_self=True)),
-            [tree.child2, tree.child2_1, tree.child2_2],
-        )
+        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()
-        self.assertEqual(
-            list(qs.filter(pk=tree.child1.pk) | qs.filter(pk=tree.child2.pk)),
-            [tree.child1, tree.child2],
-        )
+        assert list(qs.filter(pk=tree.child1.pk) | qs.filter(pk=tree.child2.pk)) == [
+            tree.child1,
+            tree.child2,
+        ]
 
     def test_twice(self):
-        self.assertEqual(list(Model.objects.with_tree_fields().with_tree_fields()), [])
+        assert list(Model.objects.with_tree_fields().with_tree_fields()) == []
 
     def test_boring_coverage(self):
-        with self.assertRaises(ValueError):
+        with pytest.raises(ValueError):
             TreeQuery(Model).get_compiler()
 
     def test_count(self):
         tree = self.create_tree()
-        self.assertEqual(Model.objects.count(), 6)
-        self.assertEqual(Model.objects.with_tree_fields().count(), 6)
-        self.assertEqual(Model.objects.with_tree_fields().distinct().count(), 6)
-
-        self.assertEqual(list(Model.objects.descendants(tree.child1)), [tree.child1_1])
-        self.assertEqual(Model.objects.descendants(tree.child1).count(), 1)
-        self.assertEqual(Model.objects.descendants(tree.child1).distinct().count(), 1)
+        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())
-        self.assertEqual(qs[0].tree_depth, 0)
-        self.assertEqual(qs[5].tree_depth, 2)
+        assert qs[0].tree_depth == 0
+        assert qs[5].tree_depth == 2
 
     def test_annotate(self):
         tree = self.create_tree()
-        self.assertEqual(
-            [
-                (node, node.children__count, node.tree_depth)
-                for node in Model.objects.with_tree_fields().annotate(Count("children"))
-            ],
-            [
-                (tree.root, 2, 0),
-                (tree.child1, 1, 1),
-                (tree.child1_1, 0, 2),
-                (tree.child2, 2, 1),
-                (tree.child2_1, 0, 2),
-                (tree.child2_2, 0, 2),
-            ],
-        )
+        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)
-        self.assertEqual(
-            Model.objects.with_tree_fields().aggregate(Sum("order")),
-            {"order__sum": 18},
-            # TODO Sum("tree_depth") does not work because the field is not
-            # known yet.
-        )
+        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()
-        self.assertEqual(
-            list(Model.objects.with_tree_fields().values("name")),
-            [
-                {"name": "root"},
-                {"name": "1"},
-                {"name": "1-1"},
-                {"name": "2"},
-                {"name": "2-1"},
-                {"name": "2-2"},
-            ],
-        )
+        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()
-        self.assertEqual(
-            list(Model.objects.ancestors(tree.child2_1).values()),
-            [
-                {
-                    "custom_id": tree.root.pk,
-                    "name": "root",
-                    "order": 0,
-                    "parent_id": None,
-                },
-                {
-                    "custom_id": tree.child2.pk,
-                    "name": "2",
-                    "order": 1,
-                    "parent_id": tree.root.pk,
-                },
-            ],
-        )
+        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()
-        self.assertEqual(
-            list(Model.objects.with_tree_fields().values_list("name", flat=True)),
-            ["root", "1", "1-1", "2", "2-1", "2-2"],
-        )
+        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()
-        self.assertEqual(
-            list(
-                Model.objects.ancestors(tree.child2_1).values_list("parent", flat=True)
-            ),
-            [tree.root.parent_id, tree.child2.parent_id],
-        )
+        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 self.assertRaises(ValidationError) as cm:
+        with pytest.raises(ValidationError) as cm:
             tree.root.full_clean()
-        self.assertEqual(
-            cm.exception.messages, ["A node cannot be made a descendant of itself."]
-        )
+        assert cm.value.messages == ["A node cannot be made a descendant of itself."]
 
         # No error.
         tree.child1.full_clean()
 
     def test_unordered(self):
-        self.assertEqual(list(UnorderedModel.objects.all()), [])
+        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)
         )
-        self.assertFalse(hasattr(obj, "tree_depth"))
+        assert not hasattr(obj, "tree_depth")
 
     def test_form_field(self):
         tree = self.create_tree()
@@ -240,8 +284,8 @@ class Test(TestCase):
                 fields = ["parent"]
 
         html = f"{Form().as_table()}"
-        self.assertIn(f'<option value="{tree.child2_1.pk}">--- --- 2-1</option>', html)
-        self.assertIn("root", html)
+        assert f'<option value="{tree.child2_1.pk}">--- --- 2-1</option>' in html
+        assert "root" in html
 
         class OtherForm(forms.Form):
             node = Model._meta.get_field("parent").formfield(
@@ -255,8 +299,8 @@ class Test(TestCase):
             )
 
         html = f"{OtherForm().as_table()}"
-        self.assertIn(f'<option value="{tree.child2_1.pk}">*** *** 2-1</option>', html)
-        self.assertNotIn("root", html)
+        assert f'<option value="{tree.child2_1.pk}">*** *** 2-1</option>' in html
+        assert "root" not in html
 
     def test_string_ordering(self):
         tree = SimpleNamespace()
@@ -282,38 +326,33 @@ class Test(TestCase):
             name="North America", parent=tree.americas
         )
 
-        self.assertEqual(
-            list(StringOrderedModel.objects.with_tree_fields()),
-            [
-                tree.americas,
-                tree.north_america,
-                tree.south_america,
-                tree.colombia,
-                tree.ecuador,
-                tree.peru,
-                tree.europe,
-                tree.france,
-            ],
-        )
-
-        self.assertEqual(
-            list(tree.peru.ancestors(include_self=True)),
-            [tree.americas, tree.south_america, tree.peru],
-        )
-
-        self.assertEqual(
-            list(
-                StringOrderedModel.objects.descendants(tree.americas, include_self=True)
-            ),
-            [
-                tree.americas,
-                tree.north_america,
-                tree.south_america,
-                tree.colombia,
-                tree.ecuador,
-                tree.peru,
-            ],
-        )
+        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")
@@ -321,38 +360,35 @@ class Test(TestCase):
             Model.objects.create(parent=root, name=f"Node {i}", order=i * 10)
 
         positions = [m.order for m in Model.objects.with_tree_fields()]
-        self.assertEqual(positions, sorted(positions))
+        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"]
         )
-        self.assertEqual(
-            list(nodes),
-            [
-                tree.root,
-                tree.child1,
-                tree.child2,
-                tree.child1_1,
-                tree.child2_1,
-                tree.child2_2,
-            ],
-        )
+        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()
 
-        self.assertTrue(hasattr(obj, "tree_depth"))
-        self.assertTrue(hasattr(obj, "tree_ordering"))
-        self.assertTrue(hasattr(obj, "tree_path"))
+        assert hasattr(obj, "tree_depth")
+        assert hasattr(obj, "tree_ordering")
+        assert hasattr(obj, "tree_path")
 
-        self.assertEqual(obj.tree_depth, 0)
+        assert obj.tree_depth == 0
 
         AlwaysTreeQueryModel.objects.update(name="Something")
         obj.refresh_from_db()
-        self.assertEqual(obj.name, "Something")
+        assert obj.name == "Something"
         AlwaysTreeQueryModel.objects.all().delete()
 
     def test_always_tree_query_relations(self):
@@ -365,12 +401,12 @@ class Test(TestCase):
 
         m3 = m2.related.get()
 
-        self.assertEqual(m1, m3)
-        self.assertEqual(m3.tree_depth, 0)
+        assert m1 == m3
+        assert m3.tree_depth == 0
 
         m4 = c.instances.get()
-        self.assertEqual(m1, m4)
-        self.assertEqual(m4.tree_depth, 0)
+        assert m1 == m4
+        assert m4.tree_depth == 0
 
     def test_reference(self):
         tree = self.create_tree()
@@ -396,93 +432,73 @@ class Test(TestCase):
             position=6, tree_field=tree.child2_2
         )
 
-        self.assertEqual(
-            list(
-                ReferenceModel.objects.filter(
-                    tree_field__in=tree.child2.descendants(include_self=True)
-                )
-            ),
-            [references.child2, references.child2_1, references.child2_2],
-        )
+        assert list(
+            ReferenceModel.objects.filter(
+                tree_field__in=tree.child2.descendants(include_self=True)
+            )
+        ) == [references.child2, references.child2_1, references.child2_2]
 
-        self.assertEqual(
-            list(
-                ReferenceModel.objects.filter(
-                    Q(tree_field__in=tree.child2.ancestors(include_self=True))
-                    | Q(tree_field__in=tree.child2.descendants(include_self=True))
-                )
-            ),
-            [
-                references.root,
-                references.child2,
-                references.child2_1,
-                references.child2_2,
-            ],
-        )
+        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,
+        ]
 
-        self.assertEqual(
-            list(
-                ReferenceModel.objects.filter(
-                    Q(tree_field__in=tree.child2_2.descendants(include_self=True))
-                    | Q(tree_field__in=tree.child1.descendants())
-                    | Q(tree_field__in=tree.child1.ancestors())
-                )
-            ),
-            [references.root, references.child1_1, references.child2_2],
-        )
+        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]
 
-        self.assertEqual(
-            list(
-                ReferenceModel.objects.exclude(
-                    Q(tree_field__in=tree.child2.ancestors(include_self=True))
-                    | Q(tree_field__in=tree.child2.descendants(include_self=True))
-                    | Q(tree_field__isnull=True)
-                )
-            ),
-            [references.child1, references.child1_1],
-        )
+        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]
 
-        self.assertEqual(
-            list(
-                ReferenceModel.objects.exclude(
-                    Q(tree_field__in=tree.child2.descendants())
-                    | Q(tree_field__in=tree.child2.ancestors())
-                    | Q(tree_field__in=tree.child1.descendants(include_self=True))
-                    | Q(tree_field__in=tree.child1.ancestors())
-                )
-            ),
-            [references.none, references.child2],
-        )
+        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]
 
-        self.assertEqual(
-            list(
-                ReferenceModel.objects.filter(
-                    Q(
-                        Q(tree_field__in=tree.child2.descendants())
-                        & ~Q(id=references.child2_2.id)
-                    )
-                    | Q(tree_field__isnull=True)
-                    | Q(tree_field__in=tree.child1.ancestors())
+        assert list(
+            ReferenceModel.objects.filter(
+                Q(
+                    Q(tree_field__in=tree.child2.descendants())
+                    & ~Q(id=references.child2_2.id)
                 )
-            ),
-            [references.none, references.root, references.child2_1],
-        )
+                | Q(tree_field__isnull=True)
+                | Q(tree_field__in=tree.child1.ancestors())
+            )
+        ) == [references.none, references.root, references.child2_1]
 
-        self.assertEqual(
-            list(
-                ReferenceModel.objects.filter(
-                    tree_field__in=tree.child2.descendants(include_self=True).filter(
-                        parent__in=tree.child2.descendants(include_self=True)
-                    )
+        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],
-        )
+            )
+        ) == [references.child2_1, references.child2_2]
 
     def test_reference_isnull_issue63(self):
         # https://github.com/feincms/django-tree-queries/issues/63
-        self.assertSequenceEqual(
-            Model.objects.with_tree_fields().exclude(referencemodel__isnull=False), []
+        assert (
+            list(Model.objects.with_tree_fields().exclude(referencemodel__isnull=False))
+            == []
         )
 
     def test_annotate_tree(self):
@@ -508,30 +524,21 @@ class Test(TestCase):
                 )
             )
 
-        self.assertEqual(
-            [(node, node.is_my_field) for node in qs],
-            [
-                (tree.root, False),
-                (tree.child2, False),
-                (tree.child2_1, True),
-                (tree.child2_2, False),
-            ],
-        )
+        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")
 
-        self.assertCountEqual(
-            root.descendants(),
-            {child1, child2},
-        )
+        assert set(root.descendants()) == {child1, child2}
 
-        self.assertEqual(
-            list(child1.ancestors(include_self=True)),
-            [root, child1],
-        )
+        assert list(child1.ancestors(include_self=True)) == [root, child1]
 
     def test_sibling_ordering(self):
         tree = SimpleNamespace()
@@ -572,13 +579,13 @@ class Test(TestCase):
         ]
 
         nodes = MultiOrderedModel.objects.order_siblings_by("second_position")
-        self.assertEqual(list(nodes), second_order)
+        assert list(nodes) == second_order
 
         nodes = MultiOrderedModel.objects.with_tree_fields()
-        self.assertEqual(list(nodes), first_order)
+        assert list(nodes) == first_order
 
         nodes = MultiOrderedModel.objects.order_siblings_by("second_position").all()
-        self.assertEqual(list(nodes), second_order)
+        assert list(nodes) == second_order
 
     def test_depth_filter(self):
         tree = self.create_tree()
@@ -587,29 +594,30 @@ class Test(TestCase):
             where=["__tree.tree_depth between %s and %s"],
             params=[0, 1],
         )
-        self.assertEqual(
-            list(nodes),
-            [
-                tree.root,
-                tree.child1,
-                # tree.child1_1,
-                tree.child2,
-                # tree.child2_1,
-                # tree.child2_2,
-            ],
-        )
+        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):
-        if connections[Model.objects.db].vendor == "postgresql":
-            explanation = Model.objects.with_tree_fields().explain()
-            self.assertIn("CTE", explanation)
+        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())
-        self.assertEqual(nodes[0].tree_depth, 0)
-        self.assertEqual(nodes[1].tree_depth, 1)
+        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"""
@@ -624,62 +632,47 @@ class Test(TestCase):
 
         # ensure we get the full tree if querying the super class
         objs = InheritParentModel.objects.with_tree_fields()
-        self.assertCountEqual(
-            [(p.name, p.tree_path) for p in objs],
-            [
-                ("root", [1]),
-                ("child1", [1, 2]),
-                ("child1_1", [1, 2, 4]),
-                ("child2", [1, 3]),
-                ("child2_1", [1, 3, 5]),
-                ("child2_2", [1, 3, 6]),
-            ],
-        )
+        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()
-        self.assertCountEqual(
-            [(p.name, p.tree_path) for p in objs],
-            [
-                ("root", [1]),
-                ("child1", [1, 2]),
-                ("child2_1", [1, 3, 5]),
-            ],
-        )
+        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()
-        self.assertCountEqual(
-            [(p.name, p.tree_path) for p in objs],
-            [
-                ("child1", [1, 2]),
-            ],
-        )
+        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()
-        self.assertCountEqual(
-            [(p.name, p.tree_path) for p in objs],
-            [
-                ("child2_2", [1, 3, 6]),
-            ],
-        )
+        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")
-        self.assertEqual(
-            list(nodes),
-            [
-                tree.root,
-                tree.child2,
-                tree.child2_2,
-                tree.child2_1,
-                tree.child1,
-                tree.child1_1,
-            ],
-        )
+        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()
@@ -704,17 +697,14 @@ class Test(TestCase):
         nodes = MultiOrderedModel.objects.order_siblings_by(
             "first_position", "-second_position"
         )
-        self.assertEqual(
-            list(nodes),
-            [
-                tree.root,
-                tree.child1,
-                tree.child1_1,
-                tree.child2,
-                tree.child2_1,
-                tree.child2_2,
-            ],
-        )
+        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()
@@ -742,46 +732,37 @@ class Test(TestCase):
         )
 
         nodes = RelatedOrderModel.objects.order_siblings_by("related__order")
-        self.assertEqual(
-            list(nodes),
-            [
-                tree.root,
-                tree.child1,
-                tree.child1_1,
-                tree.child2,
-                tree.child2_1,
-                tree.child2_2,
-            ],
-        )
+        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")
-        self.assertEqual(
-            list(nodes),
-            [
-                tree.root,
-                tree.child1,
-                tree.child1_1,
-            ],
-        )
+        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"])
-        self.assertEqual(
-            list(nodes),
-            [
-                tree.root,
-                tree.child2,
-                tree.child2_1,
-                tree.child2_2,
-            ],
-        )
+        assert list(nodes) == [
+            tree.root,
+            tree.child2,
+            tree.child2_1,
+            tree.child2_2,
+        ]
 
     def test_tree_filter_chaining(self):
         tree = self.create_tree()
@@ -790,14 +771,11 @@ class Test(TestCase):
         nodes = Model.objects.tree_exclude(name="2-2").tree_filter(
             name__in=["root", "1-1", "2", "2-1", "2-2"]
         )
-        self.assertEqual(
-            list(nodes),
-            [
-                tree.root,
-                tree.child2,
-                tree.child2_1,
-            ],
-        )
+        assert list(nodes) == [
+            tree.root,
+            tree.child2,
+            tree.child2_1,
+        ]
 
     def test_tree_filter_related(self):
         tree = SimpleNamespace()
@@ -828,14 +806,11 @@ class Test(TestCase):
         )
 
         nodes = RelatedOrderModel.objects.tree_filter(related__order=0)
-        self.assertEqual(
-            list(nodes),
-            [
-                tree.root,
-                tree.child1,
-                tree.child1_1,
-            ],
-        )
+        assert list(nodes) == [
+            tree.root,
+            tree.child1,
+            tree.child1_1,
+        ]
 
     def test_tree_filter_with_order(self):
         tree = SimpleNamespace()
@@ -863,15 +838,12 @@ class Test(TestCase):
         nodes = MultiOrderedModel.objects.tree_filter(
             first_position__gt=0
         ).order_siblings_by("-second_position")
-        self.assertEqual(
-            list(nodes),
-            [
-                tree.root,
-                tree.child2,
-                tree.child2_1,
-                tree.child2_2,
-            ],
-        )
+        assert list(nodes) == [
+            tree.root,
+            tree.child2,
+            tree.child2_1,
+            tree.child2_2,
+        ]
 
     def test_tree_filter_q_objects(self):
         tree = self.create_tree()
@@ -880,15 +852,12 @@ class Test(TestCase):
         nodes = Model.objects.tree_filter(
             Q(name__in=["root", "1-1", "2", "2-1", "2-2"])
         )
-        self.assertEqual(
-            list(nodes),
-            [
-                tree.root,
-                tree.child2,
-                tree.child2_1,
-                tree.child2_2,
-            ],
-        )
+        assert list(nodes) == [
+            tree.root,
+            tree.child2,
+            tree.child2_1,
+            tree.child2_2,
+        ]
 
     def test_tree_filter_q_mix(self):
         tree = SimpleNamespace()
@@ -916,39 +885,176 @@ class Test(TestCase):
         nodes = MultiOrderedModel.objects.tree_filter(
             Q(first_position=1), second_position=2
         )
-        self.assertEqual(
-            list(nodes),
-            [
-                tree.root,
-                tree.child2,
-                tree.child2_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]
-        self.assertEqual(
-            names,
-            [
-                ["root"],
-                ["root", "1"],
-                ["root", "1", "1-1"],
-                ["root", "2"],
-                ["root", "2", "2-1"],
-                ["root", "2", "2-2"],
-            ],
-        )
+        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]
-        self.assertEqual(
-            orders, [[0], [0, 0], [0, 0, 0], [0, 1], [0, 1, 0], [0, 1, 42]]
-        )
+        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"]
diff -pruN 0.19-1/tests/testapp/test_templatetags.py 0.20-1/tests/testapp/test_templatetags.py
--- 0.19-1/tests/testapp/test_templatetags.py	1970-01-01 00:00:00.000000000 +0000
+++ 0.20-1/tests/testapp/test_templatetags.py	2025-06-11 06:51:15.000000000 +0000
@@ -0,0 +1,790 @@
+from 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 %}<ul><li>{% else %}</li><li>{% endif %}
+        {{ item.name }}
+        {% for level in structure.closed_levels %}</li></ul>{% 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 "<ul><li>" in result
+        assert "</li></ul>" 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 %}
+        <ul>
+        {% recursetree items %}
+            <li>
+                {{ node.name }}
+                {% if children %}
+                    <ul>{{ children }}</ul>
+                {% endif %}
+            </li>
+        {% endrecursetree %}
+        </ul>
+        """)
+
+        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 "<ul>" in result
+        assert "<li>" in result
+        assert "</li>" in result
+        assert "</ul>" 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 %}
+            <div class="depth-{{ node.tree_depth }}">
+                {{ node.name }}
+                {{ children }}
+            </div>
+        {% 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 %}
+        <ul>
+        {% recursetree items %}
+            <li>{{ node.name }}</li>
+        {% endrecursetree %}
+        </ul>
+        """)
+
+        context = Context({"items": Model.objects.none()})
+        result = template.render(context)
+
+        # Should render just the outer ul
+        assert "<ul>" in result
+        assert "</ul>" in result
+        assert "<li>" 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 %}
+            <span>{{ node.name }}</span>{{ children }}
+        {% endrecursetree %}
+        """)
+
+        context = Context({"items": items})
+        result = template.render(context)
+
+        assert "lone-root" in result
+        assert "<span>" 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 %}
+            <div>{{ node.name }}{{ children }}</div>
+        {% 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 %}
+            <li>
+                {{ node.name }}
+                {% if children %}
+                    <ul class="has-children">{{ children }}</ul>
+                {% else %}
+                    <span class="leaf-node"></span>
+                {% endif %}
+            </li>
+        {% 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 %}
+            <div data-id="{{ node.pk }}" data-depth="{{ node.tree_depth }}">
+                <h{{ node.tree_depth|add:1 }}>{{ node.name }}</h{{ node.tree_depth|add:1 }}>
+                {% if children %}
+                    <div class="children-container">
+                        {{ children }}
+                    </div>
+                {% endif %}
+            </div>
+        {% 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 "<h1>" in result  # root
+        assert "<h2>" in result  # children
+        assert "<h3>" 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 %}
+            <li data-depth="{{ node.tree_depth }}">
+                {{ node.name }}
+                {% if children %}
+                    <ul>{{ children }}</ul>
+                {% endif %}
+            </li>
+        {% 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 %}
+            <span class="node">{{ node.name }}</span>{{ 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 %}
+            <div>{{ node.name }}{{ children }}</div>
+        {% 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 %}
+            <li>{{ node.name }}{{ children }}</li>
+        {% 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("<li>") == 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 %}
+            <div data-name="{{ node.name }}">
+                {{ node.name }}
+                {% if children %}[{{ children }}]{% endif %}
+            </div>
+        {% 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 %}
+            <div>{{ node.name }}{{ children }}</div>
+        {% 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 %}
+            <div data-name="{{ node.name }}" data-is-leaf="{{ is_leaf }}">
+                {{ node.name }}
+                {% if is_leaf %}[LEAF]{% endif %}
+                {{ children }}
+            </div>
+        {% 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 %}
+            <span data-node="{{ node.name }}">
+                {% if is_leaf %}LEAF:{{ node.name }}{% else %}BRANCH:{{ node.name }}{% endif %}
+                {{ children }}
+            </span>
+        {% 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 %}
+            <li data-leaf="{{ is_leaf }}">{{ node.name }}</li>
+        {% 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 %}
+            <div>{{ node.name }}{{ children }}</div>
+        {% 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 %}
+            <span>{{ node.name }}</span>{{ 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
diff -pruN 0.19-1/tox.ini 0.20-1/tox.ini
--- 0.19-1/tox.ini	2024-04-25 14:22:26.000000000 +0000
+++ 0.20-1/tox.ini	2025-06-11 06:51:15.000000000 +0000
@@ -2,7 +2,8 @@
 envlist =
     docs
     py{38,39,310}-dj{32,41,42}-{sqlite,postgresql,mysql}
-    py{310,311,312}-dj{32,41,42,50,main}-{sqlite,postgresql,mysql}
+    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 =
@@ -10,9 +11,14 @@ deps =
     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
@@ -24,29 +30,30 @@ passenv=
     GITHUB_*
     SQL
 setenv =
-    PYTHONPATH = {toxinidir}
+    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 =
-    python tests/manage.py test -v 2 {posargs:testapp}
+    pytest {posargs}
 
-[testenv:py{38,39,310,311,312}-dj{32,41,42,50,main}-postgresql]
+[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}-dj{32,41,42,50,main}-mysql]
+[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}-dj{32,41,42,50,main}-sqlite]
+[testenv:py{38,39,310,311,312,313}-dj{32,41,42,50,51,52,main}-sqlite]
 setenv =
     {[testenv]setenv}
     DB_BACKEND = sqlite3
@@ -65,6 +72,7 @@ python =
     3.10: py310
     3.11: py311
     3.12: py312
+    3.13: py313
 
 [gh-actions:env]
 DB_BACKEND =
diff -pruN 0.19-1/tree_queries/__init__.py 0.20-1/tree_queries/__init__.py
--- 0.19-1/tree_queries/__init__.py	2024-04-25 14:22:26.000000000 +0000
+++ 0.20-1/tree_queries/__init__.py	2025-06-11 06:51:15.000000000 +0000
@@ -1 +1 @@
-__version__ = "0.19.0"
+__version__ = "0.20.0"
diff -pruN 0.19-1/tree_queries/compiler.py 0.20-1/tree_queries/compiler.py
--- 0.19-1/tree_queries/compiler.py	2024-04-25 14:22:26.000000000 +0000
+++ 0.20-1/tree_queries/compiler.py	2025-06-11 06:51:15.000000000 +0000
@@ -181,6 +181,157 @@ class TreeCompiler(SQLCompiler):
     )
     """
 
+    # 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()
@@ -273,36 +424,75 @@ class TreeCompiler(SQLCompiler):
             "sep": SEPARATOR,
         }
 
-        # Get the rank_table SQL and params
-        rank_table_sql, rank_table_params = self.get_rank_table()
-        params["rank_table"] = rank_table_sql
+        # 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
-            cte_initial = "array[T.{column}]::text[], "
-            cte_recursive = "__tree.{name} || T.{column}::text, "
+            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
+            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}", T.{column}), '
+            cte_recursive = '__tree.{name} || printf("%%s{sep}", {column}), '
         elif self.connection.vendor == "mysql":
-            cte = self.CTE_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}, T2.{column}, "{sep}"), '
+            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_columns": "".join(
-                f"{qn(column)}, " for column in tree_fields.values()
-            ),
             "tree_fields_names": "".join(f"{qn(name)}, " for name in tree_fields),
             "tree_fields_initial": "".join(
-                cte_initial.format(column=qn(column), name=qn(name), sep=SEPARATOR)
+                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=qn(column), name=qn(name), sep=SEPARATOR)
+                cte_recursive.format(
+                    column=column_ref_format.format(column=qn(column)),
+                    name=qn(name),
+                    sep=SEPARATOR,
+                )
                 for name, column in tree_fields.items()
             ),
         })
@@ -326,6 +516,7 @@ class TreeCompiler(SQLCompiler):
                 "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
@@ -353,7 +544,7 @@ class TreeCompiler(SQLCompiler):
         # 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,
+            (*rank_table_params, *sql_1),
         )
 
     def get_converters(self, expressions):
diff -pruN 0.19-1/tree_queries/templatetags/tree_queries.py 0.20-1/tree_queries/templatetags/tree_queries.py
--- 0.19-1/tree_queries/templatetags/tree_queries.py	1970-01-01 00:00:00.000000000 +0000
+++ 0.20-1/tree_queries/templatetags/tree_queries.py	2025-06-11 06:51:15.000000000 +0000
@@ -0,0 +1,260 @@
+# 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 %}<ul><li>{% else %}</li><li>{% endif %}
+       {{ genre.name }}
+       {% for level in structure.closed_levels %}</li></ul>{% 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 %}
+            <li>
+                {{ node.name }}
+                {% if children %}
+                    <ul>{{ children }}</ul>
+                {% elif is_leaf %}
+                    <span class="leaf">Leaf node</span>
+                {% endif %}
+            </li>
+        {% 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)
