Selaa lähdekoodia

#21488 - Replace MPTT wtih PostgreSQL Ltree

Arthur 10 tuntia sitten
vanhempi
commit
099e6ce3b6

+ 6 - 6
netbox/dcim/graphql/types.py

@@ -347,7 +347,7 @@ class InventoryItemTemplateType(ComponentTemplateType):
 
 @strawberry_django.type(
     models.DeviceRole,
-    exclude=['path'],
+    exclude=['path', 'sort_path'],
     filters=DeviceRoleFilter,
     pagination=True
 )
@@ -526,7 +526,7 @@ class InventoryItemRoleType(OrganizationalObjectType):
 @strawberry_django.type(
     models.Location,
     # fields='__all__',
-    exclude=['parent', 'path'],  # bug - temp
+    exclude=['parent', 'path', 'sort_path'],  # bug - temp
     filters=LocationFilter,
     pagination=True
 )
@@ -590,7 +590,7 @@ class ModuleType(PrimaryObjectType):
 @strawberry_django.type(
     models.ModuleBay,
     # fields='__all__',
-    exclude=['parent', 'path'],
+    exclude=['parent', 'path', 'sort_path'],
     filters=ModuleBayFilter,
     pagination=True
 )
@@ -647,7 +647,7 @@ class ModuleTypeType(PrimaryObjectType):
 
 @strawberry_django.type(
     models.Platform,
-    exclude=['path'],
+    exclude=['path', 'sort_path'],
     filters=PlatformFilter,
     pagination=True
 )
@@ -855,7 +855,7 @@ class RearPortTemplateType(ModularComponentTemplateType):
 
 @strawberry_django.type(
     models.Region,
-    exclude=['parent', 'path'],
+    exclude=['parent', 'path', 'sort_path'],
     filters=RegionFilter,
     pagination=True
 )
@@ -916,7 +916,7 @@ class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, PrimaryObj
 
 @strawberry_django.type(
     models.SiteGroup,
-    exclude=['parent', 'path'],  # bug - temp
+    exclude=['parent', 'path', 'sort_path'],  # bug - temp
     filters=SiteGroupFilter,
     pagination=True
 )

+ 149 - 79
netbox/dcim/migrations/0234_ltree_paths.py

@@ -5,13 +5,17 @@ For each of (Region, SiteGroup, Location, DeviceRole, Platform, ModuleBay,
 InventoryItem, InventoryItemTemplate) this migration:
 
 1. Enables the PostgreSQL ltree extension (idempotent).
-2. Adds a nullable `path` LTreeField.
-3. Installs per-table BEFORE-INSERT/UPDATE-OF-parent_id and AFTER-UPDATE-OF-(parent_id, path)
-   triggers so concurrent writes during the long-running data step get correct paths.
-4. Populates paths for existing rows via a single recursive CTE per table.
-5. Tightens `path` to NOT NULL.
+2. Adds a nullable `path` LTreeField. For models that previously had
+   `MPTTMeta.order_insertion_by = ('name',)` — Region, SiteGroup, Location,
+   DeviceRole, Platform, ModuleBay — also adds a `sort_path` text column.
+3. Installs per-table BEFORE/AFTER triggers. For models with sort_path, the
+   trigger maintains both columns.
+4. Populates path (and sort_path where applicable) for existing rows via a
+   single recursive CTE per table.
+5. Tightens path to NOT NULL.
 6. Drops the legacy MPTT columns (lft, rght, tree_id, level).
-7. Adds a GiST index on the `path` column for efficient `@>` / `<@` lookups.
+7. Adds a GiST index on path (descendant/ancestor lookups via `<@` / `@>`).
+   For sort_path models, also adds a btree index for ORDER BY listing.
 """
 import django.db.models.deletion
 from django.contrib.postgres.indexes import GistIndex
@@ -21,12 +25,13 @@ from django.db import migrations, models
 import netbox.models.ltree
 from netbox.models.ltree import InstallLtreeTriggers
 
-MODELS = (
+# All models getting an ltree `path` column.
+ALL_MODELS = (
     'region', 'sitegroup', 'location', 'devicerole', 'platform',
     'inventoryitem', 'inventoryitemtemplate', 'modulebay',
 )
 
-TABLES = (
+ALL_TABLES = (
     'dcim_region',
     'dcim_sitegroup',
     'dcim_location',
@@ -37,17 +42,52 @@ TABLES = (
     'dcim_modulebay',
 )
 
+# Subset that previously declared `MPTTMeta.order_insertion_by = ('name',)` and
+# therefore needs a `sort_path` text column maintained alongside `path`.
+SORT_MODELS = ('region', 'sitegroup', 'location', 'devicerole', 'platform', 'modulebay')
+
+SORT_TABLES = (
+    'dcim_region',
+    'dcim_sitegroup',
+    'dcim_location',
+    'dcim_devicerole',
+    'dcim_platform',
+    'dcim_modulebay',
+)
+
 LEGACY_FIELDS = ('lft', 'rght', 'tree_id', 'level')
 
 
 def _populate_paths_sql():
+    """
+    Build the recursive CTE that walks each table from roots downward, computing
+    the new path (PK-based, zero-padded) and — for models with sort_path — the
+    chr(1)-separated chain of ancestor names.
+    """
     blocks = []
-    for table in TABLES:
-        blocks.append(f"""
+    for table in ALL_TABLES:
+        if table in SORT_TABLES:
+            blocks.append(f"""
+WITH RECURSIVE t(id, parent_id, path, sort_path) AS (
+    SELECT id, parent_id,
+           lpad(id::text, 19, '0')::ltree,
+           name::text
+    FROM "{table}" WHERE parent_id IS NULL
+    UNION ALL
+    SELECT r.id, r.parent_id,
+           t.path || lpad(r.id::text, 19, '0')::ltree,
+           t.sort_path || chr(1) || r.name
+    FROM "{table}" r JOIN t ON r.parent_id = t.id
+)
+UPDATE "{table}" SET path = t.path, sort_path = t.sort_path
+FROM t WHERE "{table}".id = t.id;
+""")
+        else:
+            blocks.append(f"""
 WITH RECURSIVE t(id, parent_id, path) AS (
-    SELECT id, parent_id, id::text::ltree FROM "{table}" WHERE parent_id IS NULL
+    SELECT id, parent_id, lpad(id::text, 19, '0')::ltree FROM "{table}" WHERE parent_id IS NULL
     UNION ALL
-    SELECT r.id, r.parent_id, t.path || r.id::text::ltree
+    SELECT r.id, r.parent_id, t.path || lpad(r.id::text, 19, '0')::ltree
     FROM "{table}" r JOIN t ON r.parent_id = t.id
 )
 UPDATE "{table}" SET path = t.path FROM t WHERE "{table}".id = t.id;
@@ -62,105 +102,109 @@ class Migration(migrations.Migration):
     ]
 
     operations = [
-        # Switch parent from mptt.fields.TreeForeignKey to django.db.models.ForeignKey.
-        # This is a no-op at the SQL level (TreeForeignKey is a subclass of
-        # ForeignKey producing the same column) but reconciles the migration state
-        # with the model definitions now that django-mptt is no longer used at runtime.
+        # Switch parent from mptt.fields.TreeForeignKey to django.db.models.ForeignKey
+        # (no-op at the SQL level; reconciles migration state with model definitions).
         migrations.AlterField(
-            model_name='devicerole',
-            name='parent',
-            field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
-                related_name='children', to='dcim.devicerole',
-            ),
+            model_name='devicerole', name='parent',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
+                                    related_name='children', to='dcim.devicerole'),
         ),
         migrations.AlterField(
-            model_name='inventoryitem',
-            name='parent',
-            field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
-                related_name='child_items', to='dcim.inventoryitem',
-            ),
+            model_name='inventoryitem', name='parent',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
+                                    related_name='child_items', to='dcim.inventoryitem'),
         ),
         migrations.AlterField(
-            model_name='inventoryitemtemplate',
-            name='parent',
-            field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
-                related_name='child_items', to='dcim.inventoryitemtemplate',
-            ),
+            model_name='inventoryitemtemplate', name='parent',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
+                                    related_name='child_items', to='dcim.inventoryitemtemplate'),
         ),
         migrations.AlterField(
-            model_name='location',
-            name='parent',
-            field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
-                related_name='children', to='dcim.location',
-            ),
+            model_name='location', name='parent',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
+                                    related_name='children', to='dcim.location'),
         ),
         migrations.AlterField(
-            model_name='modulebay',
-            name='parent',
-            field=models.ForeignKey(
-                blank=True, editable=False, null=True, on_delete=django.db.models.deletion.CASCADE,
-                related_name='children', to='dcim.modulebay',
-            ),
+            model_name='modulebay', name='parent',
+            field=models.ForeignKey(blank=True, editable=False, null=True,
+                                    on_delete=django.db.models.deletion.CASCADE,
+                                    related_name='children', to='dcim.modulebay'),
         ),
         migrations.AlterField(
-            model_name='platform',
-            name='parent',
-            field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
-                related_name='children', to='dcim.platform',
-            ),
+            model_name='platform', name='parent',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
+                                    related_name='children', to='dcim.platform'),
         ),
         migrations.AlterField(
-            model_name='region',
-            name='parent',
-            field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
-                related_name='children', to='dcim.region',
-            ),
+            model_name='region', name='parent',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
+                                    related_name='children', to='dcim.region'),
         ),
         migrations.AlterField(
-            model_name='sitegroup',
-            name='parent',
-            field=models.ForeignKey(
-                blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
-                related_name='children', to='dcim.sitegroup',
-            ),
+            model_name='sitegroup', name='parent',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
+                                    related_name='children', to='dcim.sitegroup'),
         ),
 
-        # 1. Enable the ltree extension (idempotent — CreateExtension emits IF NOT EXISTS)
+        # 1. Enable the ltree extension (idempotent).
         CreateExtension('ltree'),
 
-        # 2. Add nullable path column
+        # 2. Add nullable path column on all tree models.
         *[
             migrations.AddField(
-                model_name=m,
-                name='path',
+                model_name=m, name='path',
                 field=netbox.models.ltree.LtreeField(blank=True, editable=False, null=True),
             )
-            for m in MODELS
+            for m in ALL_MODELS
+        ],
+        # 2b. Add sort_path column (with default '') on the 6 models with order_insertion_by.
+        *[
+            migrations.AddField(
+                model_name=m, name='sort_path',
+                field=models.TextField(blank=True, default='', editable=False),
+            )
+            for m in SORT_MODELS
         ],
 
-        # 2. Install path-maintenance triggers
-        *[InstallLtreeTriggers(t) for t in TABLES],
+        # 3. Install path-maintenance triggers. Models with sort_path get triggers
+        #    that maintain both columns; the other two get path-only triggers.
+        *[InstallLtreeTriggers(t, name_column='name') for t in SORT_TABLES],
+        InstallLtreeTriggers('dcim_inventoryitem'),
+        InstallLtreeTriggers('dcim_inventoryitemtemplate'),
 
-        # 3. Populate existing rows
+        # 4. Populate existing rows via per-table recursive CTE.
         migrations.RunSQL(_populate_paths_sql(), reverse_sql=migrations.RunSQL.noop),
 
-        # 4. Tighten to NOT NULL with empty-string default
+        # 5. Tighten path to NOT NULL with empty-string default.
         *[
             migrations.AlterField(
-                model_name=m,
-                name='path',
+                model_name=m, name='path',
                 field=netbox.models.ltree.LtreeField(blank=True, default='', editable=False),
             )
-            for m in MODELS
+            for m in ALL_MODELS
         ],
 
-        # 5. Drop legacy (tree_id, lft) indexes added in 0226_add_mptt_tree_indexes,
+        # 6. Update Meta.ordering on the SORT_MODELS to reflect sort_path-based ordering.
+        migrations.AlterModelOptions(
+            name='devicerole', options={'ordering': ('sort_path',)},
+        ),
+        migrations.AlterModelOptions(
+            name='location', options={'ordering': ('site', 'sort_path')},
+        ),
+        migrations.AlterModelOptions(
+            name='modulebay', options={'ordering': ('device', 'sort_path')},
+        ),
+        migrations.AlterModelOptions(
+            name='platform', options={'ordering': ('sort_path',)},
+        ),
+        migrations.AlterModelOptions(
+            name='region', options={'ordering': ('sort_path',)},
+        ),
+        migrations.AlterModelOptions(
+            name='sitegroup', options={'ordering': ('sort_path',)},
+        ),
+
+        # 7. Drop legacy (tree_id, lft) indexes added in 0226_add_mptt_tree_indexes,
         # then drop the legacy MPTT columns.
         migrations.RemoveIndex(model_name='devicerole', name='dcim_devicerole_tree_id_lfbf11'),
         migrations.RemoveIndex(model_name='inventoryitem', name='dcim_inventoryitem_tree_id975c'),
@@ -172,10 +216,10 @@ class Migration(migrations.Migration):
         migrations.RemoveIndex(model_name='sitegroup', name='dcim_sitegroup_tree_id_lft_idx'),
         *[
             migrations.RemoveField(model_name=m, name=f)
-            for m in MODELS for f in LEGACY_FIELDS
+            for m in ALL_MODELS for f in LEGACY_FIELDS
         ],
 
-        # 6. Add GiST indexes on path
+        # 8. Add GiST indexes on path (descendant/ancestor containment).
         migrations.AddIndex(
             model_name='region',
             index=GistIndex(fields=['path'], name='dcim_region_path_gist'),
@@ -208,4 +252,30 @@ class Migration(migrations.Migration):
             model_name='modulebay',
             index=GistIndex(fields=['path'], name='dcim_modulebay_path_gist'),
         ),
+
+        # 9. Add btree indexes on sort_path (tree-flatten ORDER BY listing).
+        migrations.AddIndex(
+            model_name='region',
+            index=models.Index(fields=['sort_path'], name='dcim_region_sort_path_idx'),
+        ),
+        migrations.AddIndex(
+            model_name='sitegroup',
+            index=models.Index(fields=['sort_path'], name='dcim_sitegroup_sort_path_idx'),
+        ),
+        migrations.AddIndex(
+            model_name='location',
+            index=models.Index(fields=['sort_path'], name='dcim_location_sort_path_idx'),
+        ),
+        migrations.AddIndex(
+            model_name='devicerole',
+            index=models.Index(fields=['sort_path'], name='dcim_devicerole_sort_path_idx'),
+        ),
+        migrations.AddIndex(
+            model_name='platform',
+            index=models.Index(fields=['sort_path'], name='dcim_platform_sort_path_idx'),
+        ),
+        migrations.AddIndex(
+            model_name='modulebay',
+            index=models.Index(fields=['sort_path'], name='dcim_modulebay_sort_path_idx'),
+        ),
     ]

+ 2 - 3
netbox/dcim/models/device_component_templates.py

@@ -1,8 +1,8 @@
 from django.contrib.contenttypes.fields import GenericForeignKey
-from django.contrib.postgres.indexes import GistIndex
 from django.core.exceptions import ValidationError
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
+from django.contrib.postgres.indexes import GistIndex
 from django.utils.translation import gettext_lazy as _
 
 from dcim.choices import *
@@ -860,9 +860,8 @@ class InventoryItemTemplate(LtreeModel, ComponentTemplateModel):
         help_text=_('Manufacturer-assigned part identifier')
     )
 
-    component_model = InventoryItem
-
     objects = LtreeManager()
+    component_model = InventoryItem
 
     class Meta:
         ordering = ('device_type__id', 'parent__id', 'name')

+ 7 - 0
netbox/dcim/models/device_components.py

@@ -1336,14 +1336,21 @@ class ModuleBay(ModularComponentModel, TrackingModelMixin, LtreeModel):
         verbose_name=_('enabled'),
         default=True,
     )
+    sort_path = models.TextField(
+        editable=False,
+        blank=True,
+        default='',
+    )
 
     clone_fields = ('device', 'enabled')
 
     objects = LtreeManager()
 
     class Meta(ModularComponentModel.Meta):
+        ordering = ('device', 'sort_path')
         indexes = (
             GistIndex(fields=['path'], name='dcim_modulebay_path_gist'),
+            models.Index(fields=['sort_path'], name='dcim_modulebay_sort_path_idx'),
         )
         constraints = (
             models.UniqueConstraint(

+ 4 - 2
netbox/dcim/models/devices.py

@@ -412,9 +412,10 @@ class DeviceRole(NestedGroupModel):
     clone_fields = ('parent', 'description')
 
     class Meta:
-        ordering = ('name',)
+        ordering = ('sort_path',)
         indexes = (
             GistIndex(fields=['path'], name='dcim_devicerole_path_gist'),
+            models.Index(fields=['sort_path'], name='dcim_devicerole_sort_path_idx'),
         )
         constraints = (
             models.UniqueConstraint(
@@ -466,9 +467,10 @@ class Platform(NestedGroupModel):
     clone_fields = ('parent', 'description')
 
     class Meta:
-        ordering = ('name',)
+        ordering = ('sort_path',)
         indexes = (
             GistIndex(fields=['path'], name='dcim_platform_path_gist'),
+            models.Index(fields=['sort_path'], name='dcim_platform_sort_path_idx'),
         )
         verbose_name = _('platform')
         verbose_name_plural = _('platforms')

+ 6 - 1
netbox/dcim/models/sites.py

@@ -45,8 +45,10 @@ class Region(ContactsMixin, NestedGroupModel):
     )
 
     class Meta:
+        ordering = ('sort_path',)
         indexes = (
             GistIndex(fields=['path'], name='dcim_region_path_gist'),
+            models.Index(fields=['sort_path'], name='dcim_region_sort_path_idx'),
         )
         constraints = (
             models.UniqueConstraint(
@@ -104,8 +106,10 @@ class SiteGroup(ContactsMixin, NestedGroupModel):
     )
 
     class Meta:
+        ordering = ('sort_path',)
         indexes = (
             GistIndex(fields=['path'], name='dcim_sitegroup_path_gist'),
+            models.Index(fields=['sort_path'], name='dcim_sitegroup_sort_path_idx'),
         )
         constraints = (
             models.UniqueConstraint(
@@ -324,9 +328,10 @@ class Location(ContactsMixin, ImageAttachmentsMixin, NestedGroupModel):
     )
 
     class Meta:
-        ordering = ['site', 'name']
+        ordering = ('site', 'sort_path')
         indexes = (
             GistIndex(fields=['path'], name='dcim_location_path_gist'),
+            models.Index(fields=['sort_path'], name='dcim_location_sort_path_idx'),
         )
         constraints = (
             models.UniqueConstraint(

+ 2 - 0
netbox/netbox/api/viewsets/__init__.py

@@ -333,3 +333,5 @@ class NetBoxModelViewSet(
                 super().perform_destroy(instance)
         except ObjectDoesNotExist:
             raise PermissionDenied()
+
+

+ 11 - 1
netbox/netbox/models/__init__.py

@@ -162,6 +162,11 @@ class NestedGroupModel(OwnerMixin, NetBoxModel, LtreeModel):
     """
     Base model for objects which are used to form a hierarchy (regions, locations, etc.). These models nest
     recursively using PostgreSQL ltree. Within each parent, each child instance must have a unique name.
+
+    `sort_path` is a trigger-maintained text column that mirrors MPTT's `order_insertion_by=('name',)`
+    semantics: at insert and reparent time it is set to a chr(1)-separated chain of ancestor names.
+    Ordering by `sort_path` yields tree-flatten output with siblings in their column's collation order.
+    Renaming a node does NOT update `sort_path` (matching MPTT behavior).
     """
     parent = models.ForeignKey(
         to='self',
@@ -188,6 +193,11 @@ class NestedGroupModel(OwnerMixin, NetBoxModel, LtreeModel):
         verbose_name=_('comments'),
         blank=True
     )
+    sort_path = models.TextField(
+        editable=False,
+        blank=True,
+        default='',
+    )
 
     # Re-declare so the LtreeManager wins over BaseModel's RestrictedQuerySet
     # default manager via MRO resolution.
@@ -195,7 +205,7 @@ class NestedGroupModel(OwnerMixin, NetBoxModel, LtreeModel):
 
     class Meta:
         abstract = True
-        ordering = ('path',)
+        ordering = ('sort_path',)
 
     def __str__(self):
         return self.name

+ 73 - 17
netbox/netbox/models/ltree.py

@@ -189,9 +189,6 @@ class LtreeQuerySet(RestrictedQuerySet):
 class LtreeManager(models.Manager.from_queryset(LtreeQuerySet)):
     """Drop-in replacement for django-mptt's TreeManager."""
 
-    def get_queryset(self):
-        return super().get_queryset().order_by('path')
-
 
 #
 # Abstract model
@@ -252,7 +249,8 @@ class LtreeModel(models.Model):
         """Integer PK of the root, mirroring django-mptt's `tree_id`."""
         if not self.path:
             return None
-        root_label = str(self.path).split('.', 1)[0]
+        # Strip leading zeros from the padded label
+        root_label = str(self.path).split('.', 1)[0].lstrip('0') or '0'
         try:
             return int(root_label)
         except (TypeError, ValueError):
@@ -270,7 +268,7 @@ class LtreeModel(models.Model):
     def get_root(self):
         if self.is_root_node():
             return self
-        root_pk = int(str(self.path).split('.', 1)[0])
+        root_pk = int(str(self.path).split('.', 1)[0].lstrip('0') or '0')
         return type(self)._default_manager.get(pk=root_pk)
 
     def get_parent(self):
@@ -348,23 +346,26 @@ class LtreeModel(models.Model):
 # Migration operation
 #
 
-_COMPUTE_PATH_FN = '''
+# Path label is the row's PK zero-padded to 19 chars (max bigint width) so that
+# lexicographic ordering of ltree labels matches numeric PK ordering across digit
+# boundaries (e.g. "0...09" sorts before "0...10").
+_COMPUTE_PATH_ONLY_FN = '''
 CREATE OR REPLACE FUNCTION "{table}_ltree_compute_path_fn"() RETURNS TRIGGER AS $$
 DECLARE parent_path ltree;
 BEGIN
     IF NEW.parent_id IS NOT NULL THEN
         EXECUTE format('SELECT path FROM %%I WHERE id = $1', TG_TABLE_NAME)
             INTO parent_path USING NEW.parent_id;
-        NEW.path := parent_path || NEW.id::text::ltree;
+        NEW.path := parent_path || lpad(NEW.id::text, 19, '0')::ltree;
     ELSE
-        NEW.path := NEW.id::text::ltree;
+        NEW.path := lpad(NEW.id::text, 19, '0')::ltree;
     END IF;
     RETURN NEW;
 END
 $$ LANGUAGE plpgsql;
 '''
 
-_CASCADE_PATH_FN = '''
+_CASCADE_PATH_ONLY_FN = '''
 CREATE OR REPLACE FUNCTION "{table}_ltree_cascade_path_fn"() RETURNS TRIGGER AS $$
 BEGIN
     EXECUTE format(
@@ -377,6 +378,50 @@ END
 $$ LANGUAGE plpgsql;
 '''
 
+# For models with order_insertion_by=(name,) — maintain a second text column
+# `sort_path` whose value is the chain of ancestor names joined by chr(1)
+# (an unprintable separator that collates lower than any printable char in any
+# standard collation). ORDER BY sort_path then gives MPTT-equivalent
+# tree-flatten ordering with siblings in name (collation) order.
+#
+# Like MPTT's order_insertion_by, sort_path is computed at insert and
+# reparent only — renaming a node does NOT reposition it. A manual rebuild()
+# would be needed to re-sort everything by current names.
+_COMPUTE_PATH_AND_SORT_FN = '''
+CREATE OR REPLACE FUNCTION "{table}_ltree_compute_path_fn"() RETURNS TRIGGER AS $$
+DECLARE
+    parent_path ltree;
+    parent_sort_path text;
+BEGIN
+    IF NEW.parent_id IS NOT NULL THEN
+        EXECUTE format('SELECT path, sort_path FROM %%I WHERE id = $1', TG_TABLE_NAME)
+            INTO parent_path, parent_sort_path USING NEW.parent_id;
+        NEW.path := parent_path || lpad(NEW.id::text, 19, '0')::ltree;
+        NEW.sort_path := parent_sort_path || chr(1) || NEW.{name_col};
+    ELSE
+        NEW.path := lpad(NEW.id::text, 19, '0')::ltree;
+        NEW.sort_path := NEW.{name_col};
+    END IF;
+    RETURN NEW;
+END
+$$ LANGUAGE plpgsql;
+'''
+
+_CASCADE_PATH_AND_SORT_FN = '''
+CREATE OR REPLACE FUNCTION "{table}_ltree_cascade_path_fn"() RETURNS TRIGGER AS $$
+BEGIN
+    EXECUTE format(
+        'UPDATE %%I SET '
+        '  path = $1 || subpath(path, nlevel($2)), '
+        '  sort_path = $4 || substring(sort_path FROM length($5) + 1) '
+        'WHERE path <@ $2 AND id != $3',
+        TG_TABLE_NAME
+    ) USING NEW.path, OLD.path, NEW.id, NEW.sort_path, OLD.sort_path;
+    RETURN NULL;
+END
+$$ LANGUAGE plpgsql;
+'''
+
 _BEFORE_TRIGGER = '''
 CREATE TRIGGER "{table}_ltree_compute_path"
     BEFORE INSERT OR UPDATE OF parent_id ON "{table}"
@@ -397,24 +442,35 @@ class InstallLtreeTriggers(migrations.operations.base.Operation):
 
     Two row-level triggers are installed on each target table:
 
-        BEFORE INSERT OR UPDATE OF parent_id -> compute NEW.path
-        AFTER UPDATE OF path WHEN distinct   -> cascade path change to descendants
+        BEFORE INSERT OR UPDATE OF parent_id -> compute NEW.path (and sort_path if applicable)
+        AFTER UPDATE OF parent_id, path      -> cascade path/sort_path change to descendants
 
-    The trigger function bodies use TG_TABLE_NAME so the SQL is table-agnostic,
-    but each table gets its own pair of CREATE FUNCTION statements to keep
-    pg_proc entries identifiable and to avoid surprising cross-table coupling.
+    If `name_column` is provided, the model is expected to have a `sort_path`
+    text column whose value will be maintained as a chr(1)-separated chain of
+    ancestor names. This implements MPTT's `order_insertion_by=(name,)`
+    semantics: insert and reparent honor the current value of `name_column`;
+    rename does NOT reposition the node (matching MPTT behavior).
     """
     reversible = True
 
-    def __init__(self, table_name):
+    def __init__(self, table_name, name_column=None):
         self.table_name = table_name
+        self.name_column = name_column
 
     def state_forwards(self, app_label, state):
         pass
 
     def database_forwards(self, app_label, schema_editor, from_state, to_state):
-        schema_editor.execute(_COMPUTE_PATH_FN.format(table=self.table_name))
-        schema_editor.execute(_CASCADE_PATH_FN.format(table=self.table_name))
+        if self.name_column:
+            schema_editor.execute(_COMPUTE_PATH_AND_SORT_FN.format(
+                table=self.table_name, name_col=self.name_column,
+            ))
+            schema_editor.execute(_CASCADE_PATH_AND_SORT_FN.format(
+                table=self.table_name,
+            ))
+        else:
+            schema_editor.execute(_COMPUTE_PATH_ONLY_FN.format(table=self.table_name))
+            schema_editor.execute(_CASCADE_PATH_ONLY_FN.format(table=self.table_name))
         schema_editor.execute(_BEFORE_TRIGGER.format(table=self.table_name))
         schema_editor.execute(_AFTER_TRIGGER.format(table=self.table_name))
 

+ 2 - 2
netbox/tenancy/graphql/types.py

@@ -88,7 +88,7 @@ class TenantType(ContactsMixin, PrimaryObjectType):
 
 @strawberry_django.type(
     models.TenantGroup,
-    exclude=['path'],
+    exclude=['path', 'sort_path'],
     filters=TenantGroupFilter,
     pagination=True
 )
@@ -125,7 +125,7 @@ class ContactRoleType(ContactAssignmentsMixin, OrganizationalObjectType):
 
 @strawberry_django.type(
     models.ContactGroup,
-    exclude=['path'],
+    exclude=['path', 'sort_path'],
     filters=ContactGroupFilter,
     pagination=True
 )

+ 47 - 17
netbox/tenancy/migrations/0025_ltree_paths.py

@@ -16,13 +16,19 @@ def _populate_paths_sql():
     blocks = []
     for table in TABLES:
         blocks.append(f"""
-WITH RECURSIVE t(id, parent_id, path) AS (
-    SELECT id, parent_id, id::text::ltree FROM "{table}" WHERE parent_id IS NULL
+WITH RECURSIVE t(id, parent_id, path, sort_path) AS (
+    SELECT id, parent_id,
+           lpad(id::text, 19, '0')::ltree,
+           name::text
+    FROM "{table}" WHERE parent_id IS NULL
     UNION ALL
-    SELECT r.id, r.parent_id, t.path || r.id::text::ltree
+    SELECT r.id, r.parent_id,
+           t.path || lpad(r.id::text, 19, '0')::ltree,
+           t.sort_path || chr(1) || r.name
     FROM "{table}" r JOIN t ON r.parent_id = t.id
 )
-UPDATE "{table}" SET path = t.path FROM t WHERE "{table}".id = t.id;
+UPDATE "{table}" SET path = t.path, sort_path = t.sort_path
+FROM t WHERE "{table}".id = t.id;
 """)
     return '\n'.join(blocks)
 
@@ -35,51 +41,64 @@ class Migration(migrations.Migration):
 
     operations = [
         # Switch parent from mptt.fields.TreeForeignKey to django.db.models.ForeignKey.
-        # No-op at the SQL level; reconciles migration state with model definitions.
         migrations.AlterField(
-            model_name='contactgroup',
-            name='parent',
+            model_name='contactgroup', name='parent',
             field=models.ForeignKey(
                 blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
                 related_name='children', to='tenancy.contactgroup',
             ),
         ),
         migrations.AlterField(
-            model_name='tenantgroup',
-            name='parent',
+            model_name='tenantgroup', name='parent',
             field=models.ForeignKey(
                 blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
                 related_name='children', to='tenancy.tenantgroup',
             ),
         ),
 
-        # Enable the ltree extension (idempotent — CreateExtension emits IF NOT EXISTS)
         CreateExtension('ltree'),
 
+        # Add path (nullable initially) on both models.
         *[
             migrations.AddField(
-                model_name=m,
-                name='path',
+                model_name=m, name='path',
                 field=netbox.models.ltree.LtreeField(blank=True, editable=False, null=True),
             )
             for m in MODELS
         ],
+        # Add sort_path. TenantGroup gets natural_sort collation (matching its name field).
+        migrations.AddField(
+            model_name='contactgroup', name='sort_path',
+            field=models.TextField(blank=True, default='', editable=False),
+        ),
+        migrations.AddField(
+            model_name='tenantgroup', name='sort_path',
+            field=models.TextField(
+                blank=True, default='', editable=False, db_collation='natural_sort',
+            ),
+        ),
 
-        *[InstallLtreeTriggers(t) for t in TABLES],
+        # Install triggers maintaining both path and sort_path.
+        *[InstallLtreeTriggers(t, name_column='name') for t in TABLES],
 
         migrations.RunSQL(_populate_paths_sql(), reverse_sql=migrations.RunSQL.noop),
 
         *[
             migrations.AlterField(
-                model_name=m,
-                name='path',
+                model_name=m, name='path',
                 field=netbox.models.ltree.LtreeField(blank=True, default='', editable=False),
             )
             for m in MODELS
         ],
 
-        # Drop legacy (tree_id, lft) indexes added in 0023_add_mptt_tree_indexes,
-        # then drop the legacy MPTT columns.
+        migrations.AlterModelOptions(
+            name='contactgroup', options={'ordering': ('sort_path',)},
+        ),
+        migrations.AlterModelOptions(
+            name='tenantgroup', options={'ordering': ('sort_path',)},
+        ),
+
+        # Drop legacy (tree_id, lft) indexes and the MPTT columns.
         migrations.RemoveIndex(model_name='contactgroup', name='tenancy_contactgroup_tree_d2ce'),
         migrations.RemoveIndex(model_name='tenantgroup', name='tenancy_tenantgroup_tree_ifebc'),
         *[
@@ -87,6 +106,7 @@ class Migration(migrations.Migration):
             for m in MODELS for f in LEGACY_FIELDS
         ],
 
+        # GiST indexes on path.
         migrations.AddIndex(
             model_name='tenantgroup',
             index=GistIndex(fields=['path'], name='tenancy_tenantgroup_path_gist'),
@@ -95,4 +115,14 @@ class Migration(migrations.Migration):
             model_name='contactgroup',
             index=GistIndex(fields=['path'], name='tenancy_contactgroup_path_gist'),
         ),
+
+        # Btree indexes on sort_path for ORDER BY listing.
+        migrations.AddIndex(
+            model_name='tenantgroup',
+            index=models.Index(fields=['sort_path'], name='tenancy_tg_sort_path_idx'),
+        ),
+        migrations.AddIndex(
+            model_name='contactgroup',
+            index=models.Index(fields=['sort_path'], name='tenancy_cg_sort_path_idx'),
+        ),
     ]

+ 2 - 1
netbox/tenancy/models/contacts.py

@@ -47,9 +47,10 @@ class ContactGroup(NestedGroupModel):
     objects = ContactGroupManager()
 
     class Meta:
-        ordering = ['name']
+        ordering = ('sort_path',)
         indexes = (
             GistIndex(fields=['path'], name='tenancy_contactgroup_path_gist'),
+            models.Index(fields=['sort_path'], name='tenancy_cg_sort_path_idx'),
         )
         constraints = (
             models.UniqueConstraint(

+ 9 - 1
netbox/tenancy/models/tenants.py

@@ -27,11 +27,19 @@ class TenantGroup(NestedGroupModel):
         max_length=100,
         unique=True
     )
+    # Override the abstract parent's sort_path to use natural_sort, matching `name`.
+    sort_path = models.TextField(
+        editable=False,
+        blank=True,
+        default='',
+        db_collation='natural_sort',
+    )
 
     class Meta:
-        ordering = ['name']
+        ordering = ('sort_path',)
         indexes = (
             GistIndex(fields=['path'], name='tenancy_tenantgroup_path_gist'),
+            models.Index(fields=['sort_path'], name='tenancy_tg_sort_path_idx'),
         )
         verbose_name = _('tenant group')
         verbose_name_plural = _('tenant groups')

+ 2 - 2
netbox/utilities/query.py

@@ -64,8 +64,8 @@ def reapply_model_ordering(queryset: QuerySet) -> QuerySet:
     Reapply model-level ordering in case it has been lost through .annotate().
     https://code.djangoproject.com/ticket/32811
     """
-    # Hierarchical (ltree) models are exempt; their default ordering by `path` must not be
-    # clobbered by .annotate(). Use caution when annotating querysets of these models.
+    # Hierarchical (ltree) models are exempt; their default ordering by `sort_path`/`path`
+    # must not be clobbered by .annotate(). Use caution when annotating these querysets.
     if any(isinstance(manager, LtreeManager) for manager in queryset.model._meta.local_managers):
         return queryset
     if queryset.ordered:

+ 2 - 1
netbox/utilities/testing/filtersets.py

@@ -20,7 +20,8 @@ __all__ = (
 EXEMPT_MODEL_FIELDS = (
     'comments',
     'custom_field_data',
-    'path',  # ltree, trigger-maintained
+    'path',      # ltree, trigger-maintained
+    'sort_path', # ltree, trigger-maintained
 )
 
 

+ 52 - 9
netbox/utilities/tests/test_ltree.py

@@ -5,23 +5,31 @@ from django.test import TestCase
 from dcim.models import Region, Site
 
 
+def _path(*pks):
+    """Construct the expected ltree path value from a sequence of PKs.
+
+    Mirrors the trigger's zero-padded label scheme (19 chars per label).
+    """
+    return '.'.join(str(pk).zfill(19) for pk in pks)
+
+
 class LtreeTriggerTests(TestCase):
     """Verify per-row PostgreSQL triggers maintain `path` correctly."""
 
     def test_insert_root_path(self):
         r = Region.objects.create(name='Root', slug='root')
-        self.assertEqual(r.path, str(r.pk))
+        self.assertEqual(r.path, _path(r.pk))
 
     def test_insert_child_path(self):
         r = Region.objects.create(name='Root', slug='root')
         c = Region.objects.create(parent=r, name='Child', slug='child')
-        self.assertEqual(c.path, f'{r.pk}.{c.pk}')
+        self.assertEqual(c.path, _path(r.pk, c.pk))
 
     def test_grandchild_path(self):
         r = Region.objects.create(name='R', slug='r')
         c = Region.objects.create(parent=r, name='C', slug='c')
         g = Region.objects.create(parent=c, name='G', slug='g')
-        self.assertEqual(g.path, f'{r.pk}.{c.pk}.{g.pk}')
+        self.assertEqual(g.path, _path(r.pk, c.pk, g.pk))
 
     def test_move_cascades_to_descendants(self):
         r = Region.objects.create(name='R', slug='r')
@@ -31,8 +39,8 @@ class LtreeTriggerTests(TestCase):
         c.save()
         c.refresh_from_db()
         g.refresh_from_db()
-        self.assertEqual(c.path, str(c.pk))
-        self.assertEqual(g.path, f'{c.pk}.{g.pk}')
+        self.assertEqual(c.path, _path(c.pk))
+        self.assertEqual(g.path, _path(c.pk, g.pk))
 
     def test_bulk_create_populates_paths(self):
         """BEFORE INSERT trigger fires on bulk_create, populating path."""
@@ -42,7 +50,7 @@ class LtreeTriggerTests(TestCase):
         ])
         for child in children:
             child.refresh_from_db()
-            self.assertEqual(child.path, f'{root.pk}.{child.pk}')
+            self.assertEqual(child.path, _path(root.pk, child.pk))
 
     def test_queryset_update_with_parent_id_cascades(self):
         """Raw .update() that changes parent_id still fires triggers."""
@@ -54,8 +62,8 @@ class LtreeTriggerTests(TestCase):
         Region.objects.filter(pk=c.pk).update(parent=r2)
         c.refresh_from_db()
         g.refresh_from_db()
-        self.assertEqual(c.path, f'{r2.pk}.{c.pk}')
-        self.assertEqual(g.path, f'{r2.pk}.{c.pk}.{g.pk}')
+        self.assertEqual(c.path, _path(r2.pk, c.pk))
+        self.assertEqual(g.path, _path(r2.pk, c.pk, g.pk))
 
     def test_gist_index_exists(self):
         """Every ltree-backed table has a GiST index on path."""
@@ -139,7 +147,7 @@ class LtreeAPIParityTests(TestCase):
         self.leaf.move_to(new_root)
         self.leaf.refresh_from_db()
         self.assertEqual(self.leaf.parent, new_root)
-        self.assertEqual(self.leaf.path, f'{new_root.pk}.{self.leaf.pk}')
+        self.assertEqual(self.leaf.path, _path(new_root.pk, self.leaf.pk))
 
 
 class CycleValidationTests(TestCase):
@@ -155,6 +163,41 @@ class CycleValidationTests(TestCase):
             a.full_clean()
 
 
+class SortPathTests(TestCase):
+    """
+    Verify that sort_path produces tree-flatten output with siblings in name
+    order, mirroring MPTT's `order_insertion_by=('name',)` behavior.
+    """
+
+    def test_siblings_in_name_order_regardless_of_insertion_order(self):
+        # Create siblings out of name order
+        z = Region.objects.create(name='Zebra', slug='zebra-sp')
+        a = Region.objects.create(name='Aardvark', slug='aardvark-sp')
+        b = Region.objects.create(name='Buffalo', slug='buffalo-sp')
+
+        # Children of Buffalo also out of order
+        b_z = Region.objects.create(parent=b, name='Zoo', slug='b-zoo-sp')
+        b_a = Region.objects.create(parent=b, name='Apex', slug='b-apex-sp')
+
+        ordered = list(
+            Region.objects.filter(slug__endswith='-sp')
+            .order_by('sort_path')
+            .values_list('name', flat=True)
+        )
+        # Tree-flatten with siblings in name order:
+        # Aardvark, Buffalo (parent), Apex (child), Zoo (child), Zebra
+        self.assertEqual(ordered, ['Aardvark', 'Buffalo', 'Apex', 'Zoo', 'Zebra'])
+
+    def test_default_ordering_is_sort_path(self):
+        """Region.objects.all() uses sort_path-based ordering by default."""
+        b = Region.objects.create(name='B', slug='b-default')
+        a = Region.objects.create(name='A', slug='a-default')
+        names = list(
+            Region.objects.filter(slug__endswith='-default').values_list('name', flat=True)
+        )
+        self.assertEqual(names, ['A', 'B'])
+
+
 class AddRelatedCountTests(TestCase):
     """add_related_count must cumulate across subtrees via path <@."""
 

+ 1 - 1
netbox/wireless/graphql/types.py

@@ -22,7 +22,7 @@ __all__ = (
 
 @strawberry_django.type(
     models.WirelessLANGroup,
-    exclude=['path'],
+    exclude=['path', 'sort_path'],
     filters=WirelessLANGroupFilter,
     pagination=True
 )

+ 29 - 16
netbox/wireless/migrations/0020_ltree_paths.py

@@ -19,49 +19,58 @@ class Migration(migrations.Migration):
     ]
 
     operations = [
-        # Switch parent from mptt.fields.TreeForeignKey to django.db.models.ForeignKey.
-        # No-op at the SQL level; reconciles migration state with model definitions.
         migrations.AlterField(
-            model_name='wirelesslangroup',
-            name='parent',
+            model_name='wirelesslangroup', name='parent',
             field=models.ForeignKey(
                 blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
                 related_name='children', to='wireless.wirelesslangroup',
             ),
         ),
 
-        # Enable the ltree extension (idempotent — CreateExtension emits IF NOT EXISTS)
         CreateExtension('ltree'),
 
         migrations.AddField(
-            model_name=MODEL,
-            name='path',
+            model_name=MODEL, name='path',
             field=netbox.models.ltree.LtreeField(blank=True, editable=False, null=True),
         ),
+        # sort_path uses natural_sort to match the WirelessLANGroup.name collation.
+        migrations.AddField(
+            model_name=MODEL, name='sort_path',
+            field=models.TextField(
+                blank=True, default='', editable=False, db_collation='natural_sort',
+            ),
+        ),
 
-        InstallLtreeTriggers(TABLE),
+        InstallLtreeTriggers(TABLE, name_column='name'),
 
         migrations.RunSQL(
             f"""
-WITH RECURSIVE t(id, parent_id, path) AS (
-    SELECT id, parent_id, id::text::ltree FROM "{TABLE}" WHERE parent_id IS NULL
+WITH RECURSIVE t(id, parent_id, path, sort_path) AS (
+    SELECT id, parent_id,
+           lpad(id::text, 19, '0')::ltree,
+           name::text
+    FROM "{TABLE}" WHERE parent_id IS NULL
     UNION ALL
-    SELECT r.id, r.parent_id, t.path || r.id::text::ltree
+    SELECT r.id, r.parent_id,
+           t.path || lpad(r.id::text, 19, '0')::ltree,
+           t.sort_path || chr(1) || r.name
     FROM "{TABLE}" r JOIN t ON r.parent_id = t.id
 )
-UPDATE "{TABLE}" SET path = t.path FROM t WHERE "{TABLE}".id = t.id;
+UPDATE "{TABLE}" SET path = t.path, sort_path = t.sort_path
+FROM t WHERE "{TABLE}".id = t.id;
 """,
             reverse_sql=migrations.RunSQL.noop,
         ),
 
         migrations.AlterField(
-            model_name=MODEL,
-            name='path',
+            model_name=MODEL, name='path',
             field=netbox.models.ltree.LtreeField(blank=True, default='', editable=False),
         ),
 
-        # Drop legacy (tree_id, lft) index added in 0018_add_mptt_tree_indexes,
-        # then drop the legacy MPTT columns.
+        migrations.AlterModelOptions(
+            name=MODEL, options={'ordering': ('sort_path',)},
+        ),
+
         migrations.RemoveIndex(model_name=MODEL, name='wireless_wirelesslangroup_fbcd'),
         *[migrations.RemoveField(model_name=MODEL, name=f) for f in LEGACY_FIELDS],
 
@@ -69,4 +78,8 @@ UPDATE "{TABLE}" SET path = t.path FROM t WHERE "{TABLE}".id = t.id;
             model_name=MODEL,
             index=GistIndex(fields=['path'], name='wireless_lan_grp_path_gist'),
         ),
+        migrations.AddIndex(
+            model_name=MODEL,
+            index=models.Index(fields=['sort_path'], name='wireless_lan_grp_sort_idx'),
+        ),
     ]

+ 9 - 1
netbox/wireless/models.py

@@ -62,11 +62,19 @@ class WirelessLANGroup(NestedGroupModel):
         max_length=100,
         unique=True
     )
+    # Override the abstract parent's sort_path to use natural_sort, matching `name`.
+    sort_path = models.TextField(
+        editable=False,
+        blank=True,
+        default='',
+        db_collation='natural_sort',
+    )
 
     class Meta:
-        ordering = ('name', 'pk')
+        ordering = ('sort_path',)
         indexes = (
             GistIndex(fields=['path'], name='wireless_lan_grp_path_gist'),
+            models.Index(fields=['sort_path'], name='wireless_lan_grp_sort_idx'),
         )
         constraints = (
             models.UniqueConstraint(