Skip to content

TreeNodeModel is incompatible with UUID primary keys (default=uuid4): new nodes never initialize _path/_depth, get_descendants() returns the whole table #48

Description

@RiON69

Environment

  • django-fast-treenode: 3.2.10
  • Django: 5.2 / 6.0
  • Database: PostgreSQL (also reproducible on SQLite)
  • Python: 3.13

Model

import uuid
from django.db import models
from treenode.models import TreeNodeModel


class Department(TreeNodeModel):
    id = models.UUIDField(primary_key=True, editable=False, default=uuid.uuid4)
    name = models.CharField(max_length=100)

Steps to reproduce

root = Department.objects.create(name="Root")
child = Department.objects.create(name="Child", parent=root)

root.refresh_from_db(); child.refresh_from_db()
print(repr(root._path), repr(child._path))   # -> '' ''  (expected: '000', '000.000')

# With ~N unrelated rows in the table:
root.get_descendants_queryset(include_self=True).count()
# -> N (the ENTIRE table; `_path__startswith('')` matches everything)

Every save() also logs:

ERROR TreeNodeModel save error: object with pk 45bdfedb-... not found in DB

Root cause

  1. New-node detection. TreeNodeModel.save() decides whether a node is new via
    if self.pk: (models.py#L192).
    With UUIDField(default=uuid4) the pk is populated before the INSERT, so a brand-new
    instance always takes the "existing node" branch:

    • get_db_state() finds nothing → the save error log above;
    • the is_new branch never runs → _path/_depth are never generated,
      siblings are never shifted, no path-rebuild task is enqueued.
  2. PK assignment. The is_new branch does self.pk = self.db.get_next_id(), and
    SQLService.get_next_id() assumes an integer, database-generated pk
    (nextval('{table}_id_seq') on PostgreSQL, MAX(id)+1 on SQLite/MySQL) — this can
    never work for UUID pks, so even forcing the branch (pk=None) breaks.

  3. (secondary) Raw-SQL helpers (RawSQLMixin.refresh(), get_db_state()) query
    WHERE id = %s with the Python uuid.UUID value. On SQLite the stored value is a
    32-char hex string without dashes, so the query never matches (e.g. refresh()
    crashes with TypeError: cannot unpack non-iterable NoneType object inside
    get_descendants_queryset()get_order()).

Consequences

  • _path/_depth stay ''/0 for every node created through the ORM;
  • get_descendants*() filters _path__startswith('') and returns every row in the
    table
    (silently wrong data, cross-tenant leakage in multi-tenant apps);
  • add_child() / insert_at() positioning is a no-op (no sibling shift, no renormalization);
  • constant ERROR log spam on every save().

Suggested fix

  • Detect newness with self._state.adding instead of if self.pk: — works for any pk type;
  • only call get_next_id() when self.pk is None (keep application-assigned pks intact).

Happy to submit a PR if the approach sounds right.

Workaround we use meanwhile

class UUIDTreeNodeModel(TreeNodeModel):
    class Meta:
        abstract = True

    def save(self, *args, **kwargs):
        model = self._meta.model
        is_new = self._state.adding
        is_move = is_shift = False
        old_parent_id = None

        if not is_new:
            state = self.get_db_state()
            if state:
                is_shift = self.priority != state["priority"]
                is_move = self.parent_id != state["parent_id"]
                old_parent_id = state["parent_id"]

        if is_new:
            if self.priority is None:
                self.priority = BASE - 1
            self._path = self.generate_path()
            self._depth = self._path.count(".")

        if (is_new or is_move) and self.priority is not None:
            self._shift_siblings_forward()

        models.Model.save(self, *args, **kwargs)

        if is_new or is_move or is_shift:
            if is_move:
                model.tasks.add("update", old_parent_id)
            model.tasks.add("update", self.parent_id)
            model.tasks.run()
            self.clear_cache()

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions