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
-
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.
-
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.
-
(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()
Environment
Model
Steps to reproduce
Every
save()also logs:Root cause
New-node detection.
TreeNodeModel.save()decides whether a node is new viaif self.pk:(models.py#L192).With
UUIDField(default=uuid4)the pk is populated before the INSERT, so a brand-newinstance always takes the "existing node" branch:
get_db_state()finds nothing → thesave errorlog above;is_newbranch never runs →_path/_depthare never generated,siblings are never shifted, no path-rebuild task is enqueued.
PK assignment. The
is_newbranch doesself.pk = self.db.get_next_id(), andSQLService.get_next_id()assumes an integer, database-generated pk(
nextval('{table}_id_seq')on PostgreSQL,MAX(id)+1on SQLite/MySQL) — this cannever work for UUID pks, so even forcing the branch (
pk=None) breaks.(secondary) Raw-SQL helpers (
RawSQLMixin.refresh(),get_db_state()) queryWHERE id = %swith the Pythonuuid.UUIDvalue. On SQLite the stored value is a32-char hex string without dashes, so the query never matches (e.g.
refresh()crashes with
TypeError: cannot unpack non-iterable NoneType objectinsideget_descendants_queryset()→get_order()).Consequences
_path/_depthstay''/0for every node created through the ORM;get_descendants*()filters_path__startswith('')and returns every row in thetable (silently wrong data, cross-tenant leakage in multi-tenant apps);
add_child()/insert_at()positioning is a no-op (no sibling shift, no renormalization);ERRORlog spam on everysave().Suggested fix
self._state.addinginstead ofif self.pk:— works for any pk type;get_next_id()whenself.pk is None(keep application-assigned pks intact).Happy to submit a PR if the approach sounds right.
Workaround we use meanwhile