dynamicweb/cms/utils/mptree.py

878 lines
38 KiB
Python

"""
Dependency-free materialized-path tree backend
This replaces the ``django-treebeard`` dependency for the page tree
by treating ``parent_id`` as the single source of truth and maintaining
``path``/``depth``/``numchild`` as a derived, set-based-recomputed cache.
The module ships three things:
* :class:`MaterializedPath` -- a low-level *driver* implementing the tree
algorithms against any model exposing the treebeard column layout
(``path``/``depth``/``numchild``/``parent``). Operates set-based, never pulls
a subtree into Python on the hot paths, uses only cross-database ORM
functions (``Concat``/``Substr``/``Length``/``F``), and locks at *parent-row*
granularity for concurrency.
* :class:`MaterializedPathMixin` -- an abstract model exposing the subset of the
treebeard ``MP_Node`` API that django-cms uses, delegating to the driver. Its
fields are declared **identically to treebeard** so that swapping it in for
``MP_Node`` produces *no* migration.
* :func:`get_tree_base` -- selects the base class (treebeard or this) from the
``CMS_TREE_BACKEND`` setting, so the backend can be swapped per-deployment
(setting + restart, same database, no migration).
The path encoding is byte-for-byte compatible with treebeard's defaults
(base-36 alphabet, ``steplen=4``, no separators), so existing ``path`` values
remain valid in either direction.
A future redesign that removes sibling renumbering and the depth/width ceilings
("fractional ordering") is sketched in the **PHASE 2 DESIGN NOTE** at the bottom
of this module. It is intentionally *not* implemented: it requires a one-time
migration and ends treebeard byte-compatibility, so it is a deliberate later
step, not part of this drop-in backend.
"""
from collections import defaultdict
from functools import wraps
from django.db import models, router, transaction
from django.db.models import F, Value
from django.db.models.functions import Concat, Length, Substr
DEFAULT_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
DEFAULT_STEPLEN = 4
# Columns the driver plans from. Re-read under lock before planning a mutation
# so a concurrently moved/renumbered node is never laid out from stale values.
TREE_FIELDS = ["path", "depth", "numchild", "parent"]
class TreePathOverflow(ValueError):
"""
Raised when a tree position cannot be represented: a sibling step too large
for one ``steplen``-wide segment, or a path too long for the ``path`` column.
Raised *before* any statement runs, so the transaction is left untouched
rather than failing mid-rewrite on a truncated or over-long value.
"""
def _atomic_on_alias(method):
"""``transaction.atomic`` on the driver's own database, not ``default``."""
@wraps(method)
def wrapped(self, *args, **kwargs):
with transaction.atomic(using=self.db_alias):
return method(self, *args, **kwargs)
return wrapped
class MaterializedPath:
"""
Set-based materialized-path operations for a model with the treebeard
column layout: ``path`` (unique CharField), ``depth``, ``numchild`` and a
self-referential ``parent`` FK.
``scope`` is an optional dict of field lookups that partitions the forest
(e.g. ``{"site_id": 3}``). It defaults to no scope, i.e. the whole table is
one forest -- matching treebeard's behaviour for the page tree.
``using`` pins every read, write, lock and transaction to one database
alias. It defaults to the write database the router picks for ``model``, and
callers coming from a loaded instance pass that instance's alias so an
operation never straddles two connections.
"""
def __init__(
self,
model,
*,
steplen=DEFAULT_STEPLEN,
alphabet=DEFAULT_ALPHABET,
scope=None,
lock=True,
using=None,
):
self.model = model
self.steplen = steplen
self.alphabet = alphabet
self.radix = len(alphabet)
self.path_max_length = model._meta.get_field("path").max_length
self.scope = scope or {}
self.lock = lock
self.db_alias = using or router.db_for_write(model)
# -- encoding (treebeard-compatible) ---------------------------------
def _int2str(self, num):
ret = ""
num = int(num)
while True:
ret = self.alphabet[num % self.radix] + ret
if num < self.radix:
return ret
num //= self.radix
def _str2int(self, key):
num = 0
for char in key:
num = num * self.radix + self.alphabet.index(char)
return num
def segment(self, step):
"""The fixed-width path segment for a 1-based sibling ``step``."""
step = int(step)
if step < 1 or step >= self.radix**self.steplen:
raise TreePathOverflow(
f"Sibling step {step} does not fit in a {self.steplen}-character "
f"base-{self.radix} path segment."
)
key = self._int2str(step)
return self.alphabet[0] * (self.steplen - len(key)) + key
def step_of(self, path):
"""Decode the last (own) segment of ``path`` back to its integer step."""
return self._str2int(path[-self.steplen :])
# -- path length guards ----------------------------------------------
def _ensure_path_fits(self, path):
self._ensure_path_length_fits(len(path))
def _ensure_path_length_fits(self, required):
if self.path_max_length is not None and required > self.path_max_length:
raise TreePathOverflow(
f"Path requires {required} characters but "
f"{self.model._meta.label}.path allows {self.path_max_length}."
)
# -- read queries ----------------------------------------------------
def _scoped(self):
return self.model._default_manager.using(self.db_alias).filter(**self.scope)
def roots(self):
return self._scoped().filter(depth=1).order_by("path")
def children(self, node):
return self._scoped().filter(
path__startswith=node.path, depth=node.depth + 1
).order_by("path")
def descendants(self, node):
# The materialized path *is* the precomputed recursion: an indexed
# prefix scan, no WITH RECURSIVE required.
return self._scoped().filter(
path__startswith=node.path, depth__gt=node.depth
).order_by("path")
def ancestors(self, node):
paths = [
node.path[0:pos]
for pos in range(self.steplen, len(node.path), self.steplen)
]
if not paths:
return self.model._default_manager.using(self.db_alias).none()
return self._scoped().filter(path__in=paths).order_by("path")
def tree(self, parent=None):
if parent is None:
return self._scoped().order_by("path")
return self._scoped().filter(
path__startswith=parent.path, depth__gte=parent.depth
).order_by("path")
def root_of(self, node):
return self._scoped().get(path=node.path[0 : self.steplen])
def _children_rows(self, parent_path, parent_depth):
# Ordered direct children as lightweight (deferred) instances, fetched
# once and reused for index/append/layout to avoid repeat queries.
return list(
self._scoped()
.filter(path__startswith=parent_path, depth=parent_depth + 1)
.order_by("path")
.only("pk", "path", "depth")
)
def _ordered_child_pks(self, parent_path, parent_depth):
return [c.pk for c in self._children_rows(parent_path, parent_depth)]
def _last_child_step(self, parent_path, parent_depth, exclude_pk=None):
qs = self._scoped().filter(path__startswith=parent_path, depth=parent_depth + 1)
if exclude_pk is not None:
# When *moving* a node to the end of its own sibling group, its
# current slot must not count -- otherwise it would be bumped into a
# spurious gap instead of staying put.
qs = qs.exclude(pk=exclude_pk)
last = qs.order_by("path").values_list("path", flat=True).last()
return self.step_of(last) if last else 0
def _last_root_step(self):
last = self.roots().values_list("path", flat=True).last()
return self.step_of(last) if last else 0
# -- concurrency -----------------------------------------------------
def _lock_rows(self, *pks):
"""
Take row-level write locks (in pk order, to avoid deadlocks) on the rows
whose sibling slot / numchild caches an operation will touch. Parent-row
granularity: writes under different parents do not serialise against each
other. No-op on backends without ``SELECT ... FOR UPDATE`` (SQLite).
"""
if not self.lock:
return
pks = sorted({pk for pk in pks if pk is not None})
if pks:
list(
self.model._base_manager.using(self.db_alias)
.filter(pk__in=pks)
.order_by("pk")
.select_for_update()
)
def _refresh(self, *instances):
"""
Re-read the tree columns of caller-supplied instances, after locking and
before planning. The caller may have held them across an earlier request
or a concurrent move, in which case their ``path``/``depth``/``parent``
no longer describe where the node actually is -- and a layout planned
from those values rewrites the wrong rows. Only the tree columns are
reloaded, so unsaved edits the caller made to other fields survive.
"""
for instance in instances:
if instance is not None and instance.pk is not None:
instance.refresh_from_db(using=self.db_alias, fields=TREE_FIELDS)
# -- low-level subtree rewrite --------------------------------------
def _reprefix(self, *, old_prefix, new_prefix, depth_delta):
"""
Rewrite an entire subtree's paths by prefix-swap in one statement::
new path = new_prefix || (old path with its old_prefix stripped)
Never pulls descendant rows into Python.
"""
qs = self._scoped().filter(path__startswith=old_prefix)
self._ensure_path_fits(new_prefix)
prefix_growth = len(new_prefix) - len(old_prefix)
if prefix_growth > 0:
# The subtree moves deeper (or is being parked under a longer
# placeholder prefix): every descendant path grows by the same
# amount, so the deepest one decides whether the whole rewrite
# fits. One aggregate query, only on the branch that can overflow.
longest = (
qs.annotate(_mptree_path_length=Length("path"))
.order_by("-_mptree_path_length")
.values_list("_mptree_path_length", flat=True)
.first()
)
if longest is not None:
self._ensure_path_length_fits(longest + prefix_growth)
update = {"path": Concat(Value(new_prefix), Substr("path", len(old_prefix) + 1))}
if depth_delta:
update["depth"] = F("depth") + depth_delta
qs.update(**update)
def _layout(self, parent_path, parent_depth, ordered_pks, info=None):
"""
Lay the given child subtrees out as contiguous steps ``1..n`` under the
parent, in the given order, fixing each subtree's prefix *and* depth.
``info`` may carry already-fetched ``{pk: instance}`` rows (with
``path``/``depth``) so the caller's prior query can be reused.
Two correctness-preserving shortcuts, both byte-identical to a naive
full relayout:
* **Skip already-placed children.** Target slots are a permutation of
``1..n``, so a child already at its target slot is wanted by no other
child -- leaving it can never collide. Turns "insert near the end"
from O(n) rewrites into O(moved).
* **Skip the parking pass when nothing moves *down*.** Parking into the
disjoint ``~<pk>~`` namespace only exists to break collisions. If no
mover targets a lower slot than it currently holds (the usual
insert/shift-up / move-from-elsewhere case), writing movers
highest-slot-first always lands in a slot just vacated (or never
occupied), so a single pass suffices -- halving the statements.
A genuine down-move (e.g. moving a later sibling to an earlier slot)
falls back to the always-safe park-then-write.
Both shortcuts assume the movers are *disjoint* subtrees. One mover can
sit inside another -- moving a node to a slot relative to its own parent
or uncle -- and then rewriting the outer subtree first would drag the
inner one along and leave its recorded ``old_path`` matching nothing.
Those layouts always park, deepest path first, so that by the time an
ancestor is rewritten its nested mover is already out of the way.
"""
known = dict(info or {})
missing = [pk for pk in ordered_pks if pk not in known]
if missing:
for obj in self._scoped().filter(pk__in=missing).only("pk", "path", "depth"):
known[obj.pk] = obj
target_depth = parent_depth + 1
prefix_len = len(parent_path)
movers = [] # (pk, target_step, target_prefix, old_path, depth_delta, old_slot)
for step, pk in enumerate(ordered_pks, start=1):
obj = known[pk]
target_prefix = parent_path + self.segment(step)
if obj.path == target_prefix and obj.depth == target_depth:
continue # already in place; cannot collide (slots are unique)
is_child_here = (
obj.depth == target_depth
and obj.path.startswith(parent_path)
and len(obj.path) == prefix_len + self.steplen
)
old_slot = self.step_of(obj.path) if is_child_here else None
movers.append((pk, step, target_prefix, obj.path, target_depth - obj.depth, old_slot))
needs_park = any(slot is not None and step < slot for _, step, _, _, _, slot in movers)
if not needs_park:
# Nested movers (one mover's subtree contains another's) cannot be
# rewritten in place either. If a path is a proper prefix of another
# every path sorting between them shares that prefix, so comparing
# sorted neighbours is enough to spot it.
in_order = sorted(m[3] for m in movers)
needs_park = any(
b.startswith(a) for a, b in zip(in_order, in_order[1:], strict=False)
)
if needs_park:
# Deepest first: parking a nested subtree before its ancestor keeps
# the ancestor's rewrite from touching it.
for pk, _, _, old_path, _, _ in sorted(movers, key=lambda m: len(m[3]), reverse=True):
self._reprefix(old_prefix=old_path, new_prefix=f"~{pk}~", depth_delta=0)
for pk, _, target_prefix, _, depth_delta, _ in movers:
self._reprefix(old_prefix=f"~{pk}~", new_prefix=target_prefix, depth_delta=depth_delta)
else:
# highest target slot first -> destination is always free
for pk, _, target_prefix, old_path, depth_delta, _ in sorted(
movers, key=lambda m: m[1], reverse=True
):
self._reprefix(old_prefix=old_path, new_prefix=target_prefix, depth_delta=depth_delta)
# -- node construction ----------------------------------------------
def _materialise(self, instance, attrs, *, path, depth, parent):
self._ensure_path_fits(path)
# `parent`/`parent_id` are determined by the tree operation, not by the
# caller's kwargs (treebeard's add_child/add_sibling accept them as
# redundant field values) -- drop them so they can't conflict.
attrs.pop("parent", None)
attrs.pop("parent_id", None)
if instance is None:
instance = self.model(**{**self.scope, **attrs})
instance.path = path
instance.depth = depth
instance.numchild = 0
instance.parent = parent
for field, value in self.scope.items():
setattr(instance, field, value)
instance.save(using=self.db_alias)
return instance
def _bump_numchild(self, pk, delta):
if pk is not None:
self.model._base_manager.using(self.db_alias).filter(pk=pk).update(
numchild=F("numchild") + delta
)
# -- build (append + positional insert) ------------------------------
@_atomic_on_alias
def add_root(self, instance=None, **attrs):
self._lock_rows(*self.roots().values_list("pk", flat=True))
step = self._last_root_step() + 1
return self._materialise(
instance, attrs, path=self.segment(step), depth=1, parent=None
)
@_atomic_on_alias
def add_child(self, parent, position="last-child", instance=None, **attrs):
self._lock_rows(parent.pk)
self._refresh(parent)
step = self._last_child_step(parent.path, parent.depth) + 1
node = self._materialise(
instance,
attrs,
path=parent.path + self.segment(step),
depth=parent.depth + 1,
parent=parent,
)
self._bump_numchild(parent.pk, +1)
parent.numchild = (parent.numchild or 0) + 1 # keep caller's instance honest
if position == "first-child":
existing = [pk for pk in self._ordered_child_pks(parent.path, parent.depth) if pk != node.pk]
self._layout(parent.path, parent.depth, [node.pk] + existing)
self._refresh(node)
return node
@_atomic_on_alias
def add_sibling(self, node, position="last-sibling", instance=None, **attrs):
self._lock_rows(node.pk)
self._refresh(node)
parent = node.parent
if parent is None:
new = self.add_root(instance=instance, **attrs)
if position in ("first-sibling", "left"):
# rare: reorder roots is not exercised by django-cms; left to
# rebuild() which canonicalises root order.
pass
return new
new = self.add_child(parent, instance=instance, **attrs)
if position in ("last-sibling", "right"):
# 'right' relative to an arbitrary sibling still lands at the end in
# this minimal implementation unless it must sit immediately after
# `node`; place precisely:
self._place_relative(new, node, after=(position == "right"))
elif position in ("first-sibling", "left"):
self._place_relative(new, node, after=False)
return new
def _place_relative(self, node, sibling, *, after):
parent = sibling.parent
parent_path = parent.path if parent else ""
parent_depth = parent.depth if parent else 0
order = [pk for pk in self._ordered_child_pks(parent_path, parent_depth) if pk != node.pk]
idx = order.index(sibling.pk) + (1 if after else 0)
order.insert(idx, node.pk)
self._layout(parent_path, parent_depth, order)
self._refresh(node)
# -- move (single statement for append; layout for insert) -----------
@_atomic_on_alias
def move(self, node, target, pos="last-child"):
"""
Move ``node`` (and its whole subtree). Supported ``pos``:
``last-child``/``first-child`` (relative to ``target``) and
``left``/``right`` (relative to sibling ``target``).
"""
# Read the parent ids first so every row this move touches can be taken
# in one pk-ordered `_lock_rows` -- concurrent moves then queue behind
# each other instead of deadlocking on opposite acquisition orders. The
# values are unverified at this point; the lock below is what makes them
# true, and the re-read after it is what proves they still are.
parent_ids = dict(
self.model._base_manager.using(self.db_alias)
.filter(pk__in={pk for pk in (node.pk, target.pk) if pk is not None})
.values_list("pk", "parent_id")
)
self._lock_rows(
node.pk, target.pk, parent_ids.get(node.pk), parent_ids.get(target.pk)
)
# Everything below -- which parent to lock, which slot to target, the
# move-into-own-subtree check -- is planned from these tree columns, so
# they have to be the committed ones, not whatever the caller has been
# holding since an earlier request.
self._refresh(node, target)
old_parent_id = node.parent_id
relative_to_sibling = pos in ("left", "right")
new_parent_id = target.parent_id if relative_to_sibling else target.pk
# No-op when the pre-read was accurate (re-locking a row this
# transaction already holds is free); only a concurrent re-parent
# between the read and the lock makes this acquire anything new.
self._lock_rows(old_parent_id, new_parent_id)
if not relative_to_sibling:
new_parent = target
elif new_parent_id is None:
new_parent = None
else:
# Re-fetched rather than followed through `target.parent`: the
# descriptor can hand back an object cached before the lock, i.e.
# exactly the stale state re-reading `target` was meant to escape.
new_parent = self.model._base_manager.using(self.db_alias).get(
pk=new_parent_id
)
if new_parent is not None and (
target.path == node.path or new_parent.path.startswith(node.path)
):
raise ValueError("Cannot move a node into itself or its own subtree.")
parent_path = new_parent.path if new_parent else ""
parent_depth = new_parent.depth if new_parent else 0
if pos == "last-child":
# Fast path: a single set-based UPDATE, no sibling touched.
new_step = self._last_child_step(parent_path, parent_depth, exclude_pk=node.pk) + 1
new_prefix = parent_path + self.segment(new_step)
depth_delta = (parent_depth + 1) - node.depth
self._reprefix(old_prefix=node.path, new_prefix=new_prefix, depth_delta=depth_delta)
else:
# One child fetch, reused for index lookup, append step and layout.
siblings = [c for c in self._children_rows(parent_path, parent_depth) if c.pk != node.pk]
order = [c.pk for c in siblings]
if pos == "first-child":
idx = 0
elif pos == "left":
idx = order.index(target.pk)
else: # right
idx = order.index(target.pk) + 1
if idx == len(order):
# Landing at the end is an append: one set-based UPDATE, leaving
# the vacated source slot as a gap (matching treebeard, and far
# cheaper than relaying out every sibling).
new_step = (self.step_of(siblings[-1].path) + 1) if siblings else 1
new_prefix = parent_path + self.segment(new_step)
depth_delta = (parent_depth + 1) - node.depth
self._reprefix(old_prefix=node.path, new_prefix=new_prefix, depth_delta=depth_delta)
else:
order.insert(idx, node.pk)
# node carries fresh path/depth; pass everything so _layout needs
# no further query.
info = {c.pk: c for c in siblings}
info[node.pk] = node
self._layout(parent_path, parent_depth, order, info=info)
new_parent_pk = new_parent.pk if new_parent else None
self.model._base_manager.using(self.db_alias).filter(pk=node.pk).update(
parent=new_parent
)
if old_parent_id != new_parent_pk:
self._bump_numchild(old_parent_id, -1)
self._bump_numchild(new_parent_pk, +1)
# Keep the caller's in-memory instances honest: `node` moved, and
# `target` may have been renumbered (left/right) or had its numchild
# change (first/last-child) -- treebeard updates these in place too.
self._refresh(node, target)
# -- rebuild (recompute everything from parent_id) -------------------
@_atomic_on_alias
def rebuild(self):
"""
Recompute every ``path``/``depth``/``numchild`` in the scope from the
tree structure, compacting sibling steps to ``1..N`` while **preserving
the existing sibling order** (read from the current ``path``, exactly
like treebeard's ``fix_tree``). The dependency-free replacement for
``fix_tree`` -- a rare maintenance operation, written back in two
collision-free passes (park in the disjoint ``~<pk>`` namespace, then
write final values).
Ordering by ``path`` means siblings keep the order an editor arranged,
not creation order -- so no separate ``position`` field is needed for
order to survive a rebuild.
"""
rows = list(
self._scoped().order_by("path").values("pk", "parent_id")
)
present = {r["pk"] for r in rows}
children = defaultdict(list)
for r in rows:
# rows are in path order, so each parent's children list is built in
# sibling order
parent = r["parent_id"] if r["parent_id"] in present else None
children[parent].append(r["pk"])
computed = {}
stack = [(None, "", 1)]
while stack:
parent_pk, parent_path, depth = stack.pop()
for step, pk in enumerate(children.get(parent_pk, []), start=1):
path = parent_path + self.segment(step)
self._ensure_path_fits(path)
computed[pk] = [path, depth, len(children.get(pk, []))]
stack.append((pk, path, depth + 1))
self.model._base_manager.using(self.db_alias).bulk_update(
[self.model(pk=pk, path=f"~{pk}") for pk in computed],
["path"],
batch_size=500,
)
self.model._base_manager.using(self.db_alias).bulk_update(
[
self.model(pk=pk, path=p, depth=d, numchild=n)
for pk, (p, d, n) in computed.items()
],
["path", "depth", "numchild"],
batch_size=500,
)
return len(computed)
class MaterializedPathMixin(models.Model):
"""
Drop-in replacement base for treebeard's ``MP_Node`` covering the API that
django-cms uses. Fields are declared **identically to treebeard** so that
substituting this for ``MP_Node`` generates no migration.
"""
path = models.CharField(max_length=255, unique=True)
depth = models.PositiveIntegerField()
numchild = models.PositiveIntegerField(default=0)
steplen = DEFAULT_STEPLEN
alphabet = DEFAULT_ALPHABET
node_order_by = []
class Meta:
abstract = True
# -- driver ----------------------------------------------------------
@classmethod
def _tree(cls, using=None):
return MaterializedPath(
cls, steplen=cls.steplen, alphabet=cls.alphabet, using=using
)
def _instance_tree(self):
"""
Driver pinned to the database this instance was loaded from, so a node
read from a replica or a secondary alias is never queried, locked or
rewritten against ``default``.
"""
return type(self)._tree(using=self._state.db)
# -- treebeard-compatible API ---------------------------------------
@classmethod
def add_root(cls, instance=None, **attrs):
using = None
if instance is not None:
using = instance._state.db or router.db_for_write(cls, instance=instance)
return cls._tree(using=using).add_root(instance=instance, **attrs)
@classmethod
def get_root_nodes(cls, using=None):
return cls._tree(using=using).roots()
@classmethod
def get_tree(cls, parent=None, using=None):
if using is None and parent is not None:
using = parent._state.db
return cls._tree(using=using).tree(parent)
@classmethod
def fix_tree(cls, using=None, **kwargs):
return cls._tree(using=using).rebuild()
def add_child(self, instance=None, **attrs):
attrs.pop("parent", None) # redundant: the parent is `self`
return self._instance_tree().add_child(self, instance=instance, **attrs)
def add_sibling(self, pos="last-sibling", instance=None, **attrs):
attrs.pop("parent", None)
attrs.pop("parent_id", None)
return self._instance_tree().add_sibling(
self, position=pos, instance=instance, **attrs
)
def move(self, target, pos="last-child"):
self._instance_tree().move(self, target, pos)
def get_children(self):
return self._instance_tree().children(self)
def get_descendants(self):
return self._instance_tree().descendants(self)
def get_ancestors(self):
return self._instance_tree().ancestors(self)
def get_root(self):
return self._instance_tree().root_of(self)
def get_parent(self, update=False):
return self.parent
def get_first_child(self):
return self._instance_tree().children(self).first()
def is_root(self):
return self.depth == 1
def is_leaf(self):
return self.numchild == 0
def is_sibling_of(self, other):
return self.depth == other.depth and self.parent_id == other.parent_id
def is_child_of(self, other):
return self.parent_id == other.pk
def is_descendant_of(self, other):
return self.depth > other.depth and self.path.startswith(other.path)
def get_siblings(self):
if self.parent_id is None:
return self._instance_tree().roots()
return self._instance_tree().children(self.parent)
def delete(self, *args, **kwargs):
# Deleting a node removes it from its parent's child set; keep the
# parent's numchild cache correct (treebeard does this via its
# queryset/model delete overrides). Descendants are removed by the
# parent FK's on_delete cascade and do not affect surviving nodes.
parent_id = self.parent_id
# Resolved before the delete: `Model.delete` accepts `using` positionally
# as well as by keyword, and clears the instance's state afterwards.
using = (
kwargs.get("using")
or (args[0] if args else None)
or self._state.db
or router.db_for_write(type(self), instance=self)
)
result = super().delete(*args, **kwargs)
if parent_id is not None:
type(self)._base_manager.using(using).filter(pk=parent_id).update(
numchild=models.F("numchild") - 1
)
return result
def get_tree_backend() -> str:
"""
The active page-tree backend: ``"treebeard"`` (default) or ``"mptree"``,
from the ``CMS_TREE_BACKEND`` setting. An env var of the same name overrides
the setting only when the setting is not explicitly defined, which keeps the
backends swappable in CI / subprocess tests without touching the settings
module. Resolved at import time, so a change requires a process restart --
but no migration, because both backends declare identical fields.
``treebeard`` is imported lazily (only by the selectors below, only when this
returns ``"treebeard"``), so the ``mptree`` backend never imports it.
"""
import os
from django.conf import settings
return getattr(settings, "CMS_TREE_BACKEND", None) or os.environ.get(
"CMS_TREE_BACKEND", "mptree"
)
def get_tree_base() -> type:
"""Base model class for ``Page`` -- treebeard's ``MP_Node`` or our mixin."""
if get_tree_backend() == "mptree":
return MaterializedPathMixin
from treebeard.mp_tree import MP_Node
return MP_Node
def get_queryset_base() -> type:
"""
Base class for ``PageQuerySet``. Treebeard mode keeps ``MP_NodeQuerySet``
(its only contribution is a tree-fixup ``delete``); mptree mode uses a plain
Django ``QuerySet`` so treebeard is not imported at all.
"""
if get_tree_backend() == "mptree":
return models.QuerySet
from treebeard.mp_tree import MP_NodeQuerySet
return MP_NodeQuerySet
# ======================================================================
# PHASE 2 DESIGN NOTE -- FRACTIONAL ORDERING
# ======================================================================
#
# Status: NOT IMPLEMENTED. Deliberate future step. Requires a one-time data
# migration and ends treebeard byte-compatibility (so it cannot be hot-swapped
# back to treebeard). Documented for potential future dev
#
# ----------------------------------------------------------------------
# Why
# ----------------------------------------------------------------------
# This backend stores sibling order as contiguous base-36 steps inside `path`.
# Two consequences follow from that single choice:
#
# * Inserting/reordering in the middle of a sibling group renumbers the
# following siblings -- `_layout()` rewrites O(width) subtrees. (Benchmarked:
# a first-child move into a 2000-wide group is ~840 ms, on par with
# treebeard, because both renumber.) The `last-child` fast path is a single
# statement; only mid-inserts pay this.
# * Fixed `steplen=4` + `path <= 255` caps the tree at ~63 levels deep and
# 36^4 (~1.6M) siblings per node.
#
# Fractional ordering removes BOTH by separating "structure" from "order" into
# two explicit source-of-truth columns and demoting `path` to a pure read cache.
#
# ----------------------------------------------------------------------
# Model
# ----------------------------------------------------------------------
# class FractionalTreeMixin(models.Model):
# # --- source of truth ---
# parent = FK("self", null=True, on_delete=CASCADE, related_name="children")
# position = CharField(max_length=255) # fractional / LexoRank key, e.g.
# # "a0", "a0V", "a1"; sibling-local
# # --- derived read cache (rebuildable from parent_id + position) ---
# path = TextField() # SEP-joined ancestor position keys
# path_hash = CharField(max_length=40, unique=True) # sha1(path); see below
# depth = PositiveIntegerField()
# numchild = PositiveIntegerField(default=0)
#
# class Meta:
# constraints = [UniqueConstraint(fields=["parent", "position"])]
# indexes = [Index(fields=["path_hash"])] # + a prefix index for LIKE
#
# `(parent_id, position)` is the complete, minimal source of truth. `path`,
# `depth`, `numchild` are ALL recomputable from it; `path` exists only so reads
# stay indexed prefix scans (`path__startswith`) instead of recursion.
#
# Fractional key invariant: for any two sibling keys A < B there is always a key
# strictly between them (densely-ordered strings -- append a digit when A and B
# are adjacent). So `key_between(A, B)` lets you place/insert/reorder a node by
# touching ONLY that node.
#
# ----------------------------------------------------------------------
# Operation semantics (the payoff)
# ----------------------------------------------------------------------
# * Insert between A and B: position = key_between(A.position, B.position);
# path = parent.path + SEP + position. ONE row. Siblings untouched -- no
# renumber, no `_layout`, no parking. The O(width) mid-insert -> ~O(1).
# * Reorder within siblings: recompute only the moved node's position between
# its new neighbours, then one `_reprefix` of its own subtree.
# * Move to a new parent: parent_id + a new position among the new siblings +
# one set-based subtree `_reprefix` -- same single statement as today.
# * Reads: unchanged. `path__startswith(prefix + SEP)` for strict descendants
# (the separator makes prefix matching unambiguous), `order_by("path")` for
# DFS order. `position` must not contain SEP.
#
# ----------------------------------------------------------------------
# Gains
# ----------------------------------------------------------------------
# * No sibling renumbering, ever -> mid-insert/reorder are single-node; the
# per-sibling loop (and any CTE written to speed it) becomes unnecessary.
# * No ceilings: variable-length path removes the ~63-level depth cap and the
# siblings-per-node cap.
# * Concurrency: concurrent inserts at different spots compute different keys
# with no shared "max+1" counter and no sibling-row locks; same-spot inserts
# collide only on the (parent, position) unique constraint and retry. This is
# the real write-concurrency win over treebeard.
#
# ----------------------------------------------------------------------
# Costs / things to get right
# ----------------------------------------------------------------------
# * `path` is effectively unbounded (fractional keys grow under adversarial
# repeated-between inserts) -> TextField. MySQL cannot put a UNIQUE index on
# a long/text column (767/3072-byte limit), so uniqueness lives on a
# `path_hash` (sha1) column, with a prefix index on `path` for LIKE.
# * One-time migration + end of treebeard byte-compat: backfill `position` from
# each node's current sibling (path-step) order, then recompute every `path`
# in the SEP encoding. After this, hot-swapping BACK to treebeard is no
# longer possible.
# * Own `key_between(a, b)`: ~100 lines, pure Python, no dependency (base-N
# midpoint, append a digit when neighbours are adjacent). It is the
# correctness core -- test it hard (adjacent keys, empty bounds, long chains).
# * Occasional key renormalisation: if a hot spot grows keys long, a rare
# maintenance pass reassigns short keys to a parent's children (same class as
# `rebuild()`, off the hot path).
#
# ----------------------------------------------------------------------
# Net
# ----------------------------------------------------------------------
# parent_id = structure, position = order, path = indexed read cache (with depth
# /numchild), all caches rebuildable from the first two. Deletes the renumber
# problem and the depth/width ceilings; strongest concurrency story. Cost: one
# irreversible migration, a TextField path + hash for MySQL uniqueness, and
# owning key generation. Reads are unchanged.
# ======================================================================