Преглед изворни кода

Closes #23337: Employ GeneratedField to store normalized unit values

Arthur пре 3 дана
родитељ
комит
cbf133f373

+ 71 - 0
netbox/circuits/migrations/0061_generated_fields.py

@@ -0,0 +1,71 @@
+# Generated by Django 6.1.2 on 2026-10-13 23:27
+
+from decimal import Decimal
+
+import django.db.models.expressions
+from django.db import migrations, models
+
+
+# PostgreSQL cannot convert an existing column to a generated column in place, so each normalized
+# column is dropped and re-added. The database computes the values for all existing rows.
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("circuits", "0060_denormalization_triggers"),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name="circuit",
+            name="_abs_distance",
+        ),
+        migrations.AddField(
+            model_name="circuit",
+            name="_abs_distance",
+            field=models.GeneratedField(
+                db_persist=True,
+                expression=models.Case(
+                    models.When(
+                        distance_unit="km",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("distance"), "*", models.Value(Decimal("1000"))
+                        ),
+                    ),
+                    models.When(
+                        distance_unit="m",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("distance"), "*", models.Value(Decimal("1"))
+                        ),
+                    ),
+                    models.When(
+                        distance_unit="cm",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("distance"), "*", models.Value(Decimal("0.01"))
+                        ),
+                    ),
+                    models.When(
+                        distance_unit="mi",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("distance"), "*", models.Value(Decimal("1609.344"))
+                        ),
+                    ),
+                    models.When(
+                        distance_unit="ft",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("distance"), "*", models.Value(Decimal("0.3048"))
+                        ),
+                    ),
+                    models.When(
+                        distance_unit="in",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("distance"), "*", models.Value(Decimal("0.0254"))
+                        ),
+                    ),
+                ),
+                null=True,
+                output_field=models.DecimalField(
+                    blank=True, decimal_places=4, max_digits=13, null=True
+                ),
+            ),
+        ),
+    ]

+ 25 - 0
netbox/circuits/tests/test_models.py

@@ -11,8 +11,11 @@ from core.models import ObjectChange
 from dcim.models import Location, Region, Site, SiteGroup
 from extras.choices import CustomFieldStatusChoices, CustomFieldTypeChoices
 from extras.models import CustomField
+from netbox.choices import DistanceUnitChoices
 from netbox.context_managers import event_tracking
 from users.models import User
+from utilities.conversion import to_meters
+from utilities.testing.mixins import NormalizedMeasurementTestMixin
 
 
 class CircuitTerminationTestCase(TestCase):
@@ -625,3 +628,25 @@ class CircuitTerminationChangeLoggingTestCase(TestCase):
         circuit.refresh_from_db()
         self.assertIsNone(circuit.termination_a_id)
         self.assertEqual(circuit.custom_field_data, {'probe_field': 'concurrent'})
+
+
+class CircuitDistanceTestCase(NormalizedMeasurementTestMixin, TestCase):
+    model = Circuit
+    value_field = 'distance'
+    unit_field = 'distance_unit'
+    normalized_field = '_abs_distance'
+    unit_choices = DistanceUnitChoices
+    converter = staticmethod(to_meters)
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.provider = Provider.objects.create(name='Provider 1', slug='provider-1')
+        cls.circuit_type = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
+
+    def create_instance(self, **attrs):
+        return Circuit(
+            provider=self.provider,
+            type=self.circuit_type,
+            cid=f'Circuit {next(self.sequence)}',
+            **attrs
+        )

+ 634 - 0
netbox/dcim/migrations/0252_generated_fields.py

@@ -0,0 +1,634 @@
+# Generated by Django 6.1.2 on 2026-10-13 23:27
+
+from decimal import Decimal
+
+import django.db.models.expressions
+import django.db.models.functions.comparison
+import django.db.models.functions.math
+from django.db import migrations, models
+
+
+# PostgreSQL cannot convert an existing column to a generated column in place, so each normalized
+# column is dropped and re-added. The database computes the values for all existing rows.
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("dcim", "0251_fix_ltree_cascade_triggers"),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name="cable",
+            name="_abs_length",
+        ),
+        migrations.AddField(
+            model_name="cable",
+            name="_abs_length",
+            field=models.GeneratedField(
+                db_persist=True,
+                expression=models.Case(
+                    models.When(
+                        length_unit="km",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("length"), "*", models.Value(Decimal("1000"))
+                        ),
+                    ),
+                    models.When(
+                        length_unit="m",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("length"), "*", models.Value(Decimal("1"))
+                        ),
+                    ),
+                    models.When(
+                        length_unit="cm",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("length"), "*", models.Value(Decimal("0.01"))
+                        ),
+                    ),
+                    models.When(
+                        length_unit="mi",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("length"), "*", models.Value(Decimal("1609.344"))
+                        ),
+                    ),
+                    models.When(
+                        length_unit="ft",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("length"), "*", models.Value(Decimal("0.3048"))
+                        ),
+                    ),
+                    models.When(
+                        length_unit="in",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("length"), "*", models.Value(Decimal("0.0254"))
+                        ),
+                    ),
+                ),
+                null=True,
+                output_field=models.DecimalField(
+                    blank=True, decimal_places=4, max_digits=14, null=True
+                ),
+            ),
+        ),
+        migrations.RemoveField(
+            model_name="coolingfeed",
+            name="_abs_max_flow",
+        ),
+        migrations.AddField(
+            model_name="coolingfeed",
+            name="_abs_max_flow",
+            field=models.GeneratedField(
+                db_persist=True,
+                expression=models.Case(
+                    models.When(
+                        max_flow_unit="lpm",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("max_flow"), "*", models.Value(Decimal("1"))
+                        ),
+                    ),
+                    models.When(
+                        max_flow_unit="m3ph",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("max_flow"),
+                            "*",
+                            models.Value(Decimal("16.66666666666666666666666667")),
+                        ),
+                    ),
+                    models.When(
+                        max_flow_unit="gpm",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("max_flow"),
+                            "*",
+                            models.Value(Decimal("3.785411784")),
+                        ),
+                    ),
+                ),
+                null=True,
+                output_field=models.DecimalField(
+                    blank=True, decimal_places=4, max_digits=13, null=True
+                ),
+            ),
+        ),
+        migrations.RemoveField(
+            model_name="coolingintake",
+            name="_abs_diameter",
+        ),
+        migrations.AddField(
+            model_name="coolingintake",
+            name="_abs_diameter",
+            field=models.GeneratedField(
+                db_persist=True,
+                expression=models.Case(
+                    models.When(
+                        diameter_unit="mm",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("diameter"), "*", models.Value(Decimal("1"))
+                        ),
+                    ),
+                    models.When(
+                        diameter_unit="cm",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("diameter"), "*", models.Value(Decimal("10"))
+                        ),
+                    ),
+                    models.When(
+                        diameter_unit="in",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("diameter"), "*", models.Value(Decimal("25.4"))
+                        ),
+                    ),
+                ),
+                null=True,
+                output_field=models.DecimalField(
+                    blank=True, decimal_places=4, max_digits=13, null=True
+                ),
+            ),
+        ),
+        migrations.RemoveField(
+            model_name="coolingintake",
+            name="_abs_max_flow",
+        ),
+        migrations.AddField(
+            model_name="coolingintake",
+            name="_abs_max_flow",
+            field=models.GeneratedField(
+                db_persist=True,
+                expression=models.Case(
+                    models.When(
+                        max_flow_unit="lpm",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("max_flow"), "*", models.Value(Decimal("1"))
+                        ),
+                    ),
+                    models.When(
+                        max_flow_unit="m3ph",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("max_flow"),
+                            "*",
+                            models.Value(Decimal("16.66666666666666666666666667")),
+                        ),
+                    ),
+                    models.When(
+                        max_flow_unit="gpm",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("max_flow"),
+                            "*",
+                            models.Value(Decimal("3.785411784")),
+                        ),
+                    ),
+                ),
+                null=True,
+                output_field=models.DecimalField(
+                    blank=True, decimal_places=4, max_digits=13, null=True
+                ),
+            ),
+        ),
+        migrations.RemoveField(
+            model_name="coolingintaketemplate",
+            name="_abs_diameter",
+        ),
+        migrations.AddField(
+            model_name="coolingintaketemplate",
+            name="_abs_diameter",
+            field=models.GeneratedField(
+                db_persist=True,
+                expression=models.Case(
+                    models.When(
+                        diameter_unit="mm",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("diameter"), "*", models.Value(Decimal("1"))
+                        ),
+                    ),
+                    models.When(
+                        diameter_unit="cm",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("diameter"), "*", models.Value(Decimal("10"))
+                        ),
+                    ),
+                    models.When(
+                        diameter_unit="in",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("diameter"), "*", models.Value(Decimal("25.4"))
+                        ),
+                    ),
+                ),
+                null=True,
+                output_field=models.DecimalField(
+                    blank=True, decimal_places=4, max_digits=13, null=True
+                ),
+            ),
+        ),
+        migrations.RemoveField(
+            model_name="coolingintaketemplate",
+            name="_abs_max_flow",
+        ),
+        migrations.AddField(
+            model_name="coolingintaketemplate",
+            name="_abs_max_flow",
+            field=models.GeneratedField(
+                db_persist=True,
+                expression=models.Case(
+                    models.When(
+                        max_flow_unit="lpm",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("max_flow"), "*", models.Value(Decimal("1"))
+                        ),
+                    ),
+                    models.When(
+                        max_flow_unit="m3ph",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("max_flow"),
+                            "*",
+                            models.Value(Decimal("16.66666666666666666666666667")),
+                        ),
+                    ),
+                    models.When(
+                        max_flow_unit="gpm",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("max_flow"),
+                            "*",
+                            models.Value(Decimal("3.785411784")),
+                        ),
+                    ),
+                ),
+                null=True,
+                output_field=models.DecimalField(
+                    blank=True, decimal_places=4, max_digits=13, null=True
+                ),
+            ),
+        ),
+        migrations.RemoveField(
+            model_name="coolingoutflow",
+            name="_abs_diameter",
+        ),
+        migrations.AddField(
+            model_name="coolingoutflow",
+            name="_abs_diameter",
+            field=models.GeneratedField(
+                db_persist=True,
+                expression=models.Case(
+                    models.When(
+                        diameter_unit="mm",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("diameter"), "*", models.Value(Decimal("1"))
+                        ),
+                    ),
+                    models.When(
+                        diameter_unit="cm",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("diameter"), "*", models.Value(Decimal("10"))
+                        ),
+                    ),
+                    models.When(
+                        diameter_unit="in",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("diameter"), "*", models.Value(Decimal("25.4"))
+                        ),
+                    ),
+                ),
+                null=True,
+                output_field=models.DecimalField(
+                    blank=True, decimal_places=4, max_digits=13, null=True
+                ),
+            ),
+        ),
+        migrations.RemoveField(
+            model_name="coolingoutflowtemplate",
+            name="_abs_diameter",
+        ),
+        migrations.AddField(
+            model_name="coolingoutflowtemplate",
+            name="_abs_diameter",
+            field=models.GeneratedField(
+                db_persist=True,
+                expression=models.Case(
+                    models.When(
+                        diameter_unit="mm",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("diameter"), "*", models.Value(Decimal("1"))
+                        ),
+                    ),
+                    models.When(
+                        diameter_unit="cm",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("diameter"), "*", models.Value(Decimal("10"))
+                        ),
+                    ),
+                    models.When(
+                        diameter_unit="in",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("diameter"), "*", models.Value(Decimal("25.4"))
+                        ),
+                    ),
+                ),
+                null=True,
+                output_field=models.DecimalField(
+                    blank=True, decimal_places=4, max_digits=13, null=True
+                ),
+            ),
+        ),
+        migrations.RemoveField(
+            model_name="devicetype",
+            name="_abs_weight",
+        ),
+        migrations.AddField(
+            model_name="devicetype",
+            name="_abs_weight",
+            field=models.GeneratedField(
+                db_persist=True,
+                expression=django.db.models.functions.comparison.Cast(
+                    django.db.models.functions.math.Floor(
+                        models.Case(
+                            models.When(
+                                then=django.db.models.expressions.CombinedExpression(
+                                    models.F("weight"),
+                                    "*",
+                                    models.Value(Decimal("1000")),
+                                ),
+                                weight_unit="kg",
+                            ),
+                            models.When(
+                                then=django.db.models.expressions.CombinedExpression(
+                                    models.F("weight"), "*", models.Value(Decimal("1"))
+                                ),
+                                weight_unit="g",
+                            ),
+                            models.When(
+                                then=django.db.models.expressions.CombinedExpression(
+                                    models.F("weight"),
+                                    "*",
+                                    models.Value(Decimal("453.592")),
+                                ),
+                                weight_unit="lb",
+                            ),
+                            models.When(
+                                then=django.db.models.expressions.CombinedExpression(
+                                    models.F("weight"),
+                                    "*",
+                                    models.Value(Decimal("28.3495")),
+                                ),
+                                weight_unit="oz",
+                            ),
+                        )
+                    ),
+                    output_field=models.BigIntegerField(),
+                ),
+                null=True,
+                output_field=models.BigIntegerField(blank=True, null=True),
+            ),
+        ),
+        migrations.RemoveField(
+            model_name="moduletype",
+            name="_abs_weight",
+        ),
+        migrations.AddField(
+            model_name="moduletype",
+            name="_abs_weight",
+            field=models.GeneratedField(
+                db_persist=True,
+                expression=django.db.models.functions.comparison.Cast(
+                    django.db.models.functions.math.Floor(
+                        models.Case(
+                            models.When(
+                                then=django.db.models.expressions.CombinedExpression(
+                                    models.F("weight"),
+                                    "*",
+                                    models.Value(Decimal("1000")),
+                                ),
+                                weight_unit="kg",
+                            ),
+                            models.When(
+                                then=django.db.models.expressions.CombinedExpression(
+                                    models.F("weight"), "*", models.Value(Decimal("1"))
+                                ),
+                                weight_unit="g",
+                            ),
+                            models.When(
+                                then=django.db.models.expressions.CombinedExpression(
+                                    models.F("weight"),
+                                    "*",
+                                    models.Value(Decimal("453.592")),
+                                ),
+                                weight_unit="lb",
+                            ),
+                            models.When(
+                                then=django.db.models.expressions.CombinedExpression(
+                                    models.F("weight"),
+                                    "*",
+                                    models.Value(Decimal("28.3495")),
+                                ),
+                                weight_unit="oz",
+                            ),
+                        )
+                    ),
+                    output_field=models.BigIntegerField(),
+                ),
+                null=True,
+                output_field=models.BigIntegerField(blank=True, null=True),
+            ),
+        ),
+        migrations.RemoveField(
+            model_name="rack",
+            name="_abs_max_weight",
+        ),
+        migrations.AddField(
+            model_name="rack",
+            name="_abs_max_weight",
+            field=models.GeneratedField(
+                db_persist=True,
+                expression=django.db.models.functions.comparison.Cast(
+                    django.db.models.functions.math.Floor(
+                        models.Case(
+                            models.When(
+                                then=django.db.models.expressions.CombinedExpression(
+                                    models.F("max_weight"),
+                                    "*",
+                                    models.Value(Decimal("1000")),
+                                ),
+                                weight_unit="kg",
+                            ),
+                            models.When(
+                                then=django.db.models.expressions.CombinedExpression(
+                                    models.F("max_weight"),
+                                    "*",
+                                    models.Value(Decimal("1")),
+                                ),
+                                weight_unit="g",
+                            ),
+                            models.When(
+                                then=django.db.models.expressions.CombinedExpression(
+                                    models.F("max_weight"),
+                                    "*",
+                                    models.Value(Decimal("453.592")),
+                                ),
+                                weight_unit="lb",
+                            ),
+                            models.When(
+                                then=django.db.models.expressions.CombinedExpression(
+                                    models.F("max_weight"),
+                                    "*",
+                                    models.Value(Decimal("28.3495")),
+                                ),
+                                weight_unit="oz",
+                            ),
+                        )
+                    ),
+                    output_field=models.BigIntegerField(),
+                ),
+                null=True,
+                output_field=models.BigIntegerField(blank=True, null=True),
+            ),
+        ),
+        migrations.RemoveField(
+            model_name="rack",
+            name="_abs_weight",
+        ),
+        migrations.AddField(
+            model_name="rack",
+            name="_abs_weight",
+            field=models.GeneratedField(
+                db_persist=True,
+                expression=django.db.models.functions.comparison.Cast(
+                    django.db.models.functions.math.Floor(
+                        models.Case(
+                            models.When(
+                                then=django.db.models.expressions.CombinedExpression(
+                                    models.F("weight"),
+                                    "*",
+                                    models.Value(Decimal("1000")),
+                                ),
+                                weight_unit="kg",
+                            ),
+                            models.When(
+                                then=django.db.models.expressions.CombinedExpression(
+                                    models.F("weight"), "*", models.Value(Decimal("1"))
+                                ),
+                                weight_unit="g",
+                            ),
+                            models.When(
+                                then=django.db.models.expressions.CombinedExpression(
+                                    models.F("weight"),
+                                    "*",
+                                    models.Value(Decimal("453.592")),
+                                ),
+                                weight_unit="lb",
+                            ),
+                            models.When(
+                                then=django.db.models.expressions.CombinedExpression(
+                                    models.F("weight"),
+                                    "*",
+                                    models.Value(Decimal("28.3495")),
+                                ),
+                                weight_unit="oz",
+                            ),
+                        )
+                    ),
+                    output_field=models.BigIntegerField(),
+                ),
+                null=True,
+                output_field=models.BigIntegerField(blank=True, null=True),
+            ),
+        ),
+        migrations.RemoveField(
+            model_name="racktype",
+            name="_abs_max_weight",
+        ),
+        migrations.AddField(
+            model_name="racktype",
+            name="_abs_max_weight",
+            field=models.GeneratedField(
+                db_persist=True,
+                expression=django.db.models.functions.comparison.Cast(
+                    django.db.models.functions.math.Floor(
+                        models.Case(
+                            models.When(
+                                then=django.db.models.expressions.CombinedExpression(
+                                    models.F("max_weight"),
+                                    "*",
+                                    models.Value(Decimal("1000")),
+                                ),
+                                weight_unit="kg",
+                            ),
+                            models.When(
+                                then=django.db.models.expressions.CombinedExpression(
+                                    models.F("max_weight"),
+                                    "*",
+                                    models.Value(Decimal("1")),
+                                ),
+                                weight_unit="g",
+                            ),
+                            models.When(
+                                then=django.db.models.expressions.CombinedExpression(
+                                    models.F("max_weight"),
+                                    "*",
+                                    models.Value(Decimal("453.592")),
+                                ),
+                                weight_unit="lb",
+                            ),
+                            models.When(
+                                then=django.db.models.expressions.CombinedExpression(
+                                    models.F("max_weight"),
+                                    "*",
+                                    models.Value(Decimal("28.3495")),
+                                ),
+                                weight_unit="oz",
+                            ),
+                        )
+                    ),
+                    output_field=models.BigIntegerField(),
+                ),
+                null=True,
+                output_field=models.BigIntegerField(blank=True, null=True),
+            ),
+        ),
+        migrations.RemoveField(
+            model_name="racktype",
+            name="_abs_weight",
+        ),
+        migrations.AddField(
+            model_name="racktype",
+            name="_abs_weight",
+            field=models.GeneratedField(
+                db_persist=True,
+                expression=django.db.models.functions.comparison.Cast(
+                    django.db.models.functions.math.Floor(
+                        models.Case(
+                            models.When(
+                                then=django.db.models.expressions.CombinedExpression(
+                                    models.F("weight"),
+                                    "*",
+                                    models.Value(Decimal("1000")),
+                                ),
+                                weight_unit="kg",
+                            ),
+                            models.When(
+                                then=django.db.models.expressions.CombinedExpression(
+                                    models.F("weight"), "*", models.Value(Decimal("1"))
+                                ),
+                                weight_unit="g",
+                            ),
+                            models.When(
+                                then=django.db.models.expressions.CombinedExpression(
+                                    models.F("weight"),
+                                    "*",
+                                    models.Value(Decimal("453.592")),
+                                ),
+                                weight_unit="lb",
+                            ),
+                            models.When(
+                                then=django.db.models.expressions.CombinedExpression(
+                                    models.F("weight"),
+                                    "*",
+                                    models.Value(Decimal("28.3495")),
+                                ),
+                                weight_unit="oz",
+                            ),
+                        )
+                    ),
+                    output_field=models.BigIntegerField(),
+                ),
+                null=True,
+                output_field=models.BigIntegerField(blank=True, null=True),
+            ),
+        ),
+    ]

+ 14 - 30
netbox/dcim/models/cables.py

@@ -22,7 +22,7 @@ from dcim.fields import PathField
 from dcim.utils import decompile_path_node, object_to_path_node, rebuild_cable_paths
 from netbox.choices import ColorChoices
 from netbox.models import ChangeLoggedModel, PrimaryModel
-from utilities.conversion import to_meters
+from utilities.conversion import LENGTH_TO_METERS, normalized_unit_expression
 from utilities.data import normalize_update_fields
 from utilities.exceptions import AbortRequest
 from utilities.fields import ColorField, GenericArrayForeignKey
@@ -150,12 +150,12 @@ class Cable(PrimaryModel):
         blank=True,
         null=True
     )
-    # Stores the normalized length (in meters) for database ordering
-    _abs_length = models.DecimalField(
-        max_digits=14,
-        decimal_places=4,
-        blank=True,
-        null=True
+    # Normalized length (in meters), computed by the database for ordering and path tracing
+    _abs_length = models.GeneratedField(
+        expression=normalized_unit_expression('length', 'length_unit', LENGTH_TO_METERS),
+        output_field=models.DecimalField(max_digits=14, decimal_places=4, blank=True, null=True),
+        db_persist=True,
+        null=True,
     )
     bundle = models.ForeignKey(
         to='dcim.CableBundle',
@@ -353,33 +353,17 @@ class Cable(PrimaryModel):
         }
         update_fields = normalize_update_fields(save_kwargs)
 
-        length_written = update_fields is None or 'length' in update_fields
-        length_unit_written = update_fields is None or 'length_unit' in update_fields
-
-        if length_written or length_unit_written:
-            if length_written and length_unit_written:
-                stored = {}
+        # Clear length_unit if no length is defined. A save writing only the unit must check the stored length,
+        # reading from the database this save will write so a router cannot split the two.
+        if update_fields is None or 'length_unit' in update_fields:
+            if update_fields is None or 'length' in update_fields:
+                length = self.length
             else:
-                # Read from the database this save will write, so a router cannot split the two
                 db = using or router.db_for_write(Cable, instance=self)
-                stored = Cable.objects.using(db).filter(pk=self.pk).values('length', 'length_unit').first() or {}
-            length = self.length if length_written else stored.get('length')
-            length_unit = self.length_unit if length_unit_written else stored.get('length_unit')
-
-            # Clear length_unit if no length is defined
-            if length is None and length_unit_written:
+                length = Cable.objects.using(db).filter(pk=self.pk).values_list('length', flat=True).first()
+            if length is None:
                 self.length_unit = None
 
-            # Store the given length (if any) in meters for use in database ordering
-            if length is not None and length_unit:
-                self._abs_length = to_meters(length, length_unit)
-            else:
-                self._abs_length = None
-
-            # _abs_length is a denormalized cache of length and length_unit, so persist them together
-            if update_fields is not None:
-                save_kwargs['update_fields'] = update_fields | {'_abs_length'}
-
         # A field counts as changed only when this save actually writes it
         status_written = update_fields is None or 'status' in update_fields
         profile_written = update_fields is None or 'profile' in update_fields

+ 0 - 5
netbox/dcim/models/device_component_templates.py

@@ -473,9 +473,6 @@ class CoolingIntakeTemplate(DiameterMixin, MaxFlowMixin, ModularComponentTemplat
             max_flow_unit=self.max_flow_unit,
             **kwargs
         )
-        # bulk_create bypasses save(), so populate the normalized _abs_* fields here
-        component.normalize_diameter()
-        component.normalize_max_flow()
         return component
     instantiate.do_not_call_in_templates = True
 
@@ -551,8 +548,6 @@ class CoolingOutflowTemplate(DiameterMixin, ModularComponentTemplateModel):
             cooling_intake=cooling_intake,
             **kwargs
         )
-        # bulk_create bypasses save(), so populate the normalized _abs_diameter here
-        component.normalize_diameter()
         return component
     instantiate.do_not_call_in_templates = True
 

+ 17 - 26
netbox/dcim/models/mixins.py

@@ -11,8 +11,9 @@ from dcim.choices import InterfaceTypeChoices
 from dcim.constants import NONCONNECTABLE_IFACE_TYPES, VIRTUAL_IFACE_TYPES, WIRELESS_IFACE_TYPES
 from netbox.choices import *
 from utilities.conversion import (
-    to_liters_per_minute,
-    to_millimeters,
+    DIAMETER_TO_MILLIMETERS,
+    FLOW_RATE_TO_LITERS_PER_MINUTE,
+    normalized_unit_expression,
 )
 from utilities.data import normalize_update_fields
 
@@ -406,12 +407,12 @@ class DiameterMixin(models.Model):
         blank=True,
         null=True,
     )
-    # Stores the normalized diameter (in millimeters) for database ordering
-    _abs_diameter = models.DecimalField(
-        max_digits=13,
-        decimal_places=4,
-        blank=True,
-        null=True
+    # Normalized diameter (in millimeters), computed by the database for ordering
+    _abs_diameter = models.GeneratedField(
+        expression=normalized_unit_expression('diameter', 'diameter_unit', DIAMETER_TO_MILLIMETERS),
+        output_field=models.DecimalField(max_digits=13, decimal_places=4, blank=True, null=True),
+        db_persist=True,
+        null=True,
     )
 
     class Meta:
@@ -424,13 +425,8 @@ class DiameterMixin(models.Model):
 
     def normalize_diameter(self):
         """
-        Store the given diameter (if any) in millimeters for use in database ordering. Called by save(), and
-        directly by component instantiation, which bypasses save() via bulk_create().
+        Clear diameter_unit if no diameter is defined.
         """
-        if self.diameter is not None and self.diameter_unit:
-            self._abs_diameter = to_millimeters(self.diameter, self.diameter_unit)
-        else:
-            self._abs_diameter = None
         if self.diameter is None:
             self.diameter_unit = None
     normalize_diameter.alters_data = True
@@ -468,12 +464,12 @@ class MaxFlowMixin(models.Model):
         blank=True,
         null=True,
     )
-    # Stores the normalized max flow (in liters per minute) for database ordering
-    _abs_max_flow = models.DecimalField(
-        max_digits=13,
-        decimal_places=4,
-        blank=True,
-        null=True
+    # Normalized max flow (in liters per minute), computed by the database for ordering
+    _abs_max_flow = models.GeneratedField(
+        expression=normalized_unit_expression('max_flow', 'max_flow_unit', FLOW_RATE_TO_LITERS_PER_MINUTE),
+        output_field=models.DecimalField(max_digits=13, decimal_places=4, blank=True, null=True),
+        db_persist=True,
+        null=True,
     )
 
     class Meta:
@@ -486,13 +482,8 @@ class MaxFlowMixin(models.Model):
 
     def normalize_max_flow(self):
         """
-        Store the given max flow (if any) in liters per minute for use in database ordering. Called by save(),
-        and directly by component instantiation, which bypasses save() via bulk_create().
+        Clear max_flow_unit if no max flow is defined.
         """
-        if self.max_flow is not None and self.max_flow_unit:
-            self._abs_max_flow = to_liters_per_minute(self.max_flow, self.max_flow_unit)
-        else:
-            self._abs_max_flow = None
         if self.max_flow is None:
             self.max_flow_unit = None
     normalize_max_flow.alters_data = True

+ 7 - 17
netbox/dcim/models/racks.py

@@ -17,7 +17,7 @@ from netbox.choices import ColorChoices
 from netbox.models import OrganizationalModel, PrimaryModel
 from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
 from netbox.models.mixins import WeightMixin
-from utilities.conversion import to_grams
+from utilities.conversion import WEIGHT_TO_GRAMS, normalized_unit_expression
 from utilities.data import array_to_string, drange
 from utilities.fields import ColorField, CounterCacheField
 from utilities.tracking import TrackingModelMixin
@@ -129,10 +129,12 @@ class RackBase(WeightMixin, PrimaryModel):
         null=True,
         help_text=_('Maximum load capacity for the rack')
     )
-    # Stores the normalized max weight (in grams) for database ordering
-    _abs_max_weight = models.PositiveBigIntegerField(
-        blank=True,
-        null=True
+    # Normalized max weight (in grams), computed by the database for ordering
+    _abs_max_weight = models.GeneratedField(
+        expression=normalized_unit_expression('max_weight', 'weight_unit', WEIGHT_TO_GRAMS, integer=True),
+        output_field=models.BigIntegerField(blank=True, null=True),
+        db_persist=True,
+        null=True,
     )
 
     # Cooling
@@ -236,12 +238,6 @@ class RackType(ImageAttachmentsMixin, RackBase):
             raise ValidationError(_("Must specify a unit when setting a maximum weight"))
 
     def save(self, *args, **kwargs):
-        # Store the given max weight (if any) in grams for use in database ordering
-        if self.max_weight and self.weight_unit:
-            self._abs_max_weight = to_grams(self.max_weight, self.weight_unit)
-        else:
-            self._abs_max_weight = None
-
         # Clear unit if outer width & depth are not set
         if not any([self.outer_width, self.outer_depth, self.outer_height]):
             self.outer_unit = None
@@ -475,12 +471,6 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, TrackingModelMixin, RackBase):
     def save(self, *args, **kwargs):
         self.copy_racktype_attrs()
 
-        # Store the given max weight (if any) in grams for use in database ordering
-        if self.max_weight and self.weight_unit:
-            self._abs_max_weight = to_grams(self.max_weight, self.weight_unit)
-        else:
-            self._abs_max_weight = None
-
         # Clear unit if outer width & depth are not set
         if not any([self.outer_width, self.outer_depth, self.outer_height]):
             self.outer_unit = None

+ 8 - 4
netbox/dcim/tests/test_models.py

@@ -3313,9 +3313,10 @@ class CableTestCase(TestCase):
         self.assertIsNone(cable.length_unit)
         self.assertIsNone(cable._abs_length)
 
-    def test_partial_save_reads_the_stored_pair_only_for_an_excluded_field(self):
+    def test_partial_save_reads_the_stored_length_only_for_an_unwritten_length(self):
         """
-        A save writing both source fields must normalize without reading the row back.
+        Only a save writing length_unit without length must read the row back, to decide whether to clear the
+        unit; the database computes the normalized length itself.
         """
         cable = Cable.objects.first()
         cable.length = Decimal('1')
@@ -3324,14 +3325,17 @@ class CableTestCase(TestCase):
 
         with CaptureQueriesContext(connection) as both_written:
             cable.save(update_fields=['length', 'length_unit'])
-        with CaptureQueriesContext(connection) as one_written:
+        with CaptureQueriesContext(connection) as length_written:
             cable.save(update_fields=['length'])
+        with CaptureQueriesContext(connection) as unit_written:
+            cable.save(update_fields=['length_unit'])
 
         def cable_reads(queries):
             return [q for q in queries if q['sql'].startswith('SELECT') and 'FROM "dcim_cable"' in q['sql']]
 
         self.assertEqual(cable_reads(both_written), [])
-        self.assertEqual(len(cable_reads(one_written)), 1)
+        self.assertEqual(cable_reads(length_written), [])
+        self.assertEqual(len(cable_reads(unit_written)), 1)
 
 
 class CableTerminationTestCase(TestCase):

+ 13 - 27
netbox/netbox/models/mixins.py

@@ -3,7 +3,7 @@ from django.db import models
 from django.utils.translation import gettext_lazy as _
 
 from netbox.choices import *
-from utilities.conversion import to_grams, to_meters
+from utilities.conversion import LENGTH_TO_METERS, WEIGHT_TO_GRAMS, normalized_unit_expression
 
 __all__ = (
     'DistanceMixin',
@@ -43,10 +43,12 @@ class WeightMixin(models.Model):
         blank=True,
         null=True,
     )
-    # Stores the normalized weight (in grams) for database ordering
-    _abs_weight = models.PositiveBigIntegerField(
-        blank=True,
-        null=True
+    # Normalized weight (in grams), computed by the database for ordering
+    _abs_weight = models.GeneratedField(
+        expression=normalized_unit_expression('weight', 'weight_unit', WEIGHT_TO_GRAMS, integer=True),
+        output_field=models.BigIntegerField(blank=True, null=True),
+        db_persist=True,
+        null=True,
     )
 
     class Meta:
@@ -57,16 +59,6 @@ class WeightMixin(models.Model):
         # Public alias for _abs_weight; Django templates cannot access underscore-prefixed attributes.
         return self._abs_weight
 
-    def save(self, *args, **kwargs):
-
-        # Store the given weight (if any) in grams for use in database ordering
-        if self.weight and self.weight_unit:
-            self._abs_weight = to_grams(self.weight, self.weight_unit)
-        else:
-            self._abs_weight = None
-
-        super().save(*args, **kwargs)
-
     def clean(self):
         super().clean()
 
@@ -90,12 +82,12 @@ class DistanceMixin(models.Model):
         blank=True,
         null=True,
     )
-    # Stores the normalized distance (in meters) for database ordering
-    _abs_distance = models.DecimalField(
-        max_digits=13,
-        decimal_places=4,
-        blank=True,
-        null=True
+    # Normalized distance (in meters), computed by the database for ordering
+    _abs_distance = models.GeneratedField(
+        expression=normalized_unit_expression('distance', 'distance_unit', LENGTH_TO_METERS),
+        output_field=models.DecimalField(max_digits=13, decimal_places=4, blank=True, null=True),
+        db_persist=True,
+        null=True,
     )
 
     class Meta:
@@ -107,12 +99,6 @@ class DistanceMixin(models.Model):
         return self._abs_distance
 
     def save(self, *args, **kwargs):
-        # Store the given distance (if any) in meters for use in database ordering
-        if self.distance is not None and self.distance_unit:
-            self._abs_distance = to_meters(self.distance, self.distance_unit)
-        else:
-            self._abs_distance = None
-
         # Clear distance_unit if no distance is defined
         if self.distance is None:
             self.distance_unit = None

+ 78 - 31
netbox/utilities/conversion.py

@@ -1,5 +1,7 @@
 from decimal import Decimal, InvalidOperation
 
+from django.db.models import BigIntegerField, Case, F, Value, When
+from django.db.models.functions import Cast, Floor
 from django.utils.translation import gettext as _
 
 from dcim.choices import CableLengthUnitChoices
@@ -10,6 +12,11 @@ from netbox.choices import (
 )
 
 __all__ = (
+    'DIAMETER_TO_MILLIMETERS',
+    'FLOW_RATE_TO_LITERS_PER_MINUTE',
+    'LENGTH_TO_METERS',
+    'WEIGHT_TO_GRAMS',
+    'normalized_unit_expression',
     'to_grams',
     'to_liters_per_minute',
     'to_meters',
@@ -17,12 +24,72 @@ __all__ = (
 )
 
 
-def _normalized_measurement(value, unit, converters, quantity, precision=4) -> Decimal:
+#
+# Conversion factors
+#
+
+# Each mapping gives the exact multiplier from a unit to its base unit. The to_*() helpers and the GeneratedField
+# expressions built by normalized_unit_expression() both read these, so changing a factor (or adding a unit) requires
+# a migration to drop and re-add the affected generated columns.
+WEIGHT_TO_GRAMS = {
+    WeightUnitChoices.UNIT_KILOGRAM: Decimal('1000'),
+    WeightUnitChoices.UNIT_GRAM: Decimal('1'),
+    WeightUnitChoices.UNIT_POUND: Decimal('453.592'),
+    WeightUnitChoices.UNIT_OUNCE: Decimal('28.3495'),
+}
+
+# Shared by cable lengths and distances; DistanceUnitChoices is a subset of CableLengthUnitChoices
+LENGTH_TO_METERS = {
+    CableLengthUnitChoices.UNIT_KILOMETER: Decimal('1000'),
+    CableLengthUnitChoices.UNIT_METER: Decimal('1'),
+    CableLengthUnitChoices.UNIT_CENTIMETER: Decimal('0.01'),
+    CableLengthUnitChoices.UNIT_MILE: Decimal('1609.344'),
+    CableLengthUnitChoices.UNIT_FOOT: Decimal('0.3048'),
+    CableLengthUnitChoices.UNIT_INCH: Decimal('0.0254'),
+}
+
+DIAMETER_TO_MILLIMETERS = {
+    DiameterUnitChoices.UNIT_MILLIMETER: Decimal('1'),
+    DiameterUnitChoices.UNIT_CENTIMETER: Decimal('10'),
+    DiameterUnitChoices.UNIT_INCH: Decimal('25.4'),
+}
+
+FLOW_RATE_TO_LITERS_PER_MINUTE = {
+    FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE: Decimal('1'),
+    FlowRateUnitChoices.UNIT_CUBIC_METERS_PER_HOUR: Decimal('1000') / Decimal('60'),
+    FlowRateUnitChoices.UNIT_GALLONS_PER_MINUTE: Decimal('3.785411784'),
+}
+
+
+#
+# Database expressions
+#
+
+def normalized_unit_expression(value_field, unit_field, factors, integer=False):
+    """
+    Return a database expression converting `value_field` to its base unit according to the value of `unit_field`,
+    per the given mapping of unit to factor. Evaluates to NULL if either field is NULL or the unit is unrecognized.
+    With `integer=True`, the result is truncated to a whole number (as to_grams() does) and cast to a bigint.
+    """
+    expression = Case(*[
+        When(**{unit_field: unit}, then=F(value_field) * Value(factor))
+        for unit, factor in factors.items()
+    ])
+    if integer:
+        expression = Cast(Floor(expression), output_field=BigIntegerField())
+    return expression
+
+
+#
+# Python conversions
+#
+
+def _normalized_measurement(value, unit, factors, quantity, precision=4) -> Decimal:
     """
     Shared implementation for the unit-normalization helpers below. Coerce `value` to a non-negative
-    Decimal, apply the matching per-unit converter, and round the result to `precision` decimal places.
-    `converters` maps each valid unit to a callable receiving the Decimal value; `quantity` labels the
-    value in error messages. Pass `precision=None` to return the unrounded result.
+    Decimal, multiply by the factor for `unit`, and round the result to `precision` decimal places.
+    `factors` maps each valid unit to its multiplier; `quantity` labels the value in error messages.
+    Pass `precision=None` to return the unrounded result.
     """
     try:
         value = Decimal(value)
@@ -32,14 +99,14 @@ def _normalized_measurement(value, unit, converters, quantity, precision=4) -> D
         )
     if value < 0:
         raise ValueError(_("Invalid value for {quantity}: must be a positive number").format(quantity=quantity))
-    if unit not in converters:
+    if unit not in factors:
         raise ValueError(
             _("Unknown unit {unit}. Must be one of the following: {valid_units}").format(
                 unit=unit,
-                valid_units=', '.join(converters)
+                valid_units=', '.join(factors)
             )
         )
-    result = converters[unit](value)
+    result = value * factors[unit]
     return result if precision is None else round(result, precision)
 
 
@@ -49,45 +116,25 @@ def to_grams(weight, unit) -> int:
     """
     # Rounding is suppressed so that the result is truncated exactly as the caller's int() expects; rounding
     # first could nudge a value across an integer boundary.
-    return int(_normalized_measurement(weight, unit, {
-        WeightUnitChoices.UNIT_KILOGRAM: lambda v: v * 1000,
-        WeightUnitChoices.UNIT_GRAM: lambda v: v,
-        WeightUnitChoices.UNIT_POUND: lambda v: v * Decimal(453.592),
-        WeightUnitChoices.UNIT_OUNCE: lambda v: v * Decimal(28.3495),
-    }, _('weight'), precision=None))
+    return int(_normalized_measurement(weight, unit, WEIGHT_TO_GRAMS, _('weight'), precision=None))
 
 
 def to_meters(length, unit) -> Decimal:
     """
     Convert the given length to meters, returning a Decimal value.
     """
-    return _normalized_measurement(length, unit, {
-        CableLengthUnitChoices.UNIT_KILOMETER: lambda v: v * 1000,
-        CableLengthUnitChoices.UNIT_METER: lambda v: v,
-        CableLengthUnitChoices.UNIT_CENTIMETER: lambda v: v / 100,
-        CableLengthUnitChoices.UNIT_MILE: lambda v: v * Decimal(1609.344),
-        CableLengthUnitChoices.UNIT_FOOT: lambda v: v * Decimal(0.3048),
-        CableLengthUnitChoices.UNIT_INCH: lambda v: v * Decimal(0.0254),
-    }, _('length'))
+    return _normalized_measurement(length, unit, LENGTH_TO_METERS, _('length'))
 
 
 def to_millimeters(diameter, unit) -> Decimal:
     """
     Convert the given diameter to millimeters, returning a Decimal value.
     """
-    return _normalized_measurement(diameter, unit, {
-        DiameterUnitChoices.UNIT_MILLIMETER: lambda v: v,
-        DiameterUnitChoices.UNIT_CENTIMETER: lambda v: v * 10,
-        DiameterUnitChoices.UNIT_INCH: lambda v: v * Decimal('25.4'),
-    }, _('diameter'))
+    return _normalized_measurement(diameter, unit, DIAMETER_TO_MILLIMETERS, _('diameter'))
 
 
 def to_liters_per_minute(flow_rate, unit) -> Decimal:
     """
     Convert the given flow rate to liters per minute, returning a Decimal value.
     """
-    return _normalized_measurement(flow_rate, unit, {
-        FlowRateUnitChoices.UNIT_LITERS_PER_MINUTE: lambda v: v,
-        FlowRateUnitChoices.UNIT_CUBIC_METERS_PER_HOUR: lambda v: v * Decimal(1000) / Decimal(60),
-        FlowRateUnitChoices.UNIT_GALLONS_PER_MINUTE: lambda v: v * Decimal('3.785411784'),
-    }, _('flow rate'))
+    return _normalized_measurement(flow_rate, unit, FLOW_RATE_TO_LITERS_PER_MINUTE, _('flow rate'))

+ 93 - 0
netbox/utilities/testing/mixins.py

@@ -1,9 +1,13 @@
+import itertools
+from decimal import Decimal
+
 from django.test.testcases import SerializeMixin
 from django_rq import get_queue
 from django_rq.workers import get_worker
 from rq import SimpleWorker
 
 __all__ = (
+    'NormalizedMeasurementTestMixin',
     'RQQueueTestMixin',
 )
 
@@ -54,3 +58,92 @@ class RQQueueTestMixin(SerializeMixin):
             self.clear_rq_queues()
         finally:
             super().tearDown()
+
+
+class NormalizedMeasurementTestMixin:
+    """
+    Exercise a database-generated column that normalizes a measurement to its base unit. The column is checked
+    against the matching to_*() helper for every unit choice, across each write path: save(), QuerySet.update(),
+    bulk_update(), and bulk_create(). Subclasses set the attributes below and implement create_instance().
+    """
+    model = None
+    value_field = None       # e.g. 'distance'
+    unit_field = None        # e.g. 'distance_unit'
+    normalized_field = None  # e.g. '_abs_distance'
+    unit_choices = None      # e.g. DistanceUnitChoices
+    converter = None         # e.g. staticmethod(to_meters)
+    value = Decimal('123.45')
+
+    def setUp(self):
+        super().setUp()
+        self.sequence = itertools.count(1)
+
+    def create_instance(self, **attrs):
+        """
+        Return an unsaved instance of the model with the given attributes set. Each call must yield an instance
+        which can be saved alongside those from previous calls; next(self.sequence) supplies a unique number.
+        """
+        raise NotImplementedError
+
+    @property
+    def units(self):
+        return self.unit_choices.values()
+
+    def assertNormalized(self, instance, value, unit):
+        """
+        Assert that the stored normalized value matches the converter's result for the given measurement.
+        """
+        expected = self.converter(value, unit) if value is not None and unit else None
+        instance.refresh_from_db()
+        self.assertEqual(getattr(instance, self.normalized_field), expected)
+
+    def test_save(self):
+        for unit in self.units:
+            with self.subTest(unit=unit):
+                instance = self.create_instance(**{self.value_field: self.value, self.unit_field: unit})
+                instance.save()
+                # The database returns the computed value on save, so it is available without a refresh
+                self.assertEqual(getattr(instance, self.normalized_field), self.converter(self.value, unit))
+                self.assertNormalized(instance, self.value, unit)
+
+    def test_update(self):
+        instance = self.create_instance()
+        instance.save()
+        for unit in self.units:
+            with self.subTest(unit=unit):
+                self.model.objects.filter(pk=instance.pk).update(**{
+                    self.value_field: self.value,
+                    self.unit_field: unit,
+                })
+                self.assertNormalized(instance, self.value, unit)
+
+    def test_bulk_update(self):
+        instance = self.create_instance()
+        instance.save()
+        for unit in self.units:
+            with self.subTest(unit=unit):
+                setattr(instance, self.value_field, self.value)
+                setattr(instance, self.unit_field, unit)
+                self.model.objects.bulk_update([instance], [self.value_field, self.unit_field])
+                self.assertNormalized(instance, self.value, unit)
+
+    def test_bulk_create(self):
+        instances = self.model.objects.bulk_create([
+            self.create_instance(**{self.value_field: self.value, self.unit_field: unit})
+            for unit in self.units
+        ])
+        for instance, unit in zip(instances, self.units):
+            with self.subTest(unit=unit):
+                self.assertNormalized(instance, self.value, unit)
+
+    def test_null_without_value_or_unit(self):
+        unit = self.units[0]
+        instance = self.create_instance(**{self.value_field: self.value, self.unit_field: unit})
+        instance.save()
+        queryset = self.model.objects.filter(pk=instance.pk)
+
+        queryset.update(**{self.value_field: None})
+        self.assertNormalized(instance, None, unit)
+
+        queryset.update(**{self.value_field: self.value, self.unit_field: None})
+        self.assertNormalized(instance, self.value, None)

+ 22 - 0
netbox/utilities/tests/test_conversions.py

@@ -3,10 +3,15 @@ from decimal import Decimal
 from dcim.choices import CableLengthUnitChoices
 from netbox.choices import (
     DiameterUnitChoices,
+    DistanceUnitChoices,
     FlowRateUnitChoices,
     WeightUnitChoices,
 )
 from utilities.conversion import (
+    DIAMETER_TO_MILLIMETERS,
+    FLOW_RATE_TO_LITERS_PER_MINUTE,
+    LENGTH_TO_METERS,
+    WEIGHT_TO_GRAMS,
     to_grams,
     to_liters_per_minute,
     to_meters,
@@ -17,6 +22,23 @@ from utilities.testing.base import TestCase
 
 class ConversionsTestCase(TestCase):
 
+    def test_factors_cover_all_units(self):
+        """
+        Every unit choice must have a conversion factor, as the factors drive both the to_*() helpers and the
+        generated columns which normalize measurements.
+        """
+        for factors, choices in (
+            (WEIGHT_TO_GRAMS, WeightUnitChoices),
+            (LENGTH_TO_METERS, CableLengthUnitChoices),
+            (LENGTH_TO_METERS, DistanceUnitChoices),
+            (DIAMETER_TO_MILLIMETERS, DiameterUnitChoices),
+            (FLOW_RATE_TO_LITERS_PER_MINUTE, FlowRateUnitChoices),
+        ):
+            with self.subTest(choices=choices.__name__):
+                self.assertTrue(set(choices.values()) <= set(factors))
+                for factor in factors.values():
+                    self.assertIsInstance(factor, Decimal)
+
     def test_to_grams(self):
         self.assertEqual(
             to_grams(1, WeightUnitChoices.UNIT_KILOGRAM),

+ 71 - 0
netbox/wireless/migrations/0025_generated_fields.py

@@ -0,0 +1,71 @@
+# Generated by Django 6.1.2 on 2026-10-13 23:27
+
+from decimal import Decimal
+
+import django.db.models.expressions
+from django.db import migrations, models
+
+
+# PostgreSQL cannot convert an existing column to a generated column in place, so each normalized
+# column is dropped and re-added. The database computes the values for all existing rows.
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("wireless", "0024_fix_ltree_cascade_triggers"),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name="wirelesslink",
+            name="_abs_distance",
+        ),
+        migrations.AddField(
+            model_name="wirelesslink",
+            name="_abs_distance",
+            field=models.GeneratedField(
+                db_persist=True,
+                expression=models.Case(
+                    models.When(
+                        distance_unit="km",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("distance"), "*", models.Value(Decimal("1000"))
+                        ),
+                    ),
+                    models.When(
+                        distance_unit="m",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("distance"), "*", models.Value(Decimal("1"))
+                        ),
+                    ),
+                    models.When(
+                        distance_unit="cm",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("distance"), "*", models.Value(Decimal("0.01"))
+                        ),
+                    ),
+                    models.When(
+                        distance_unit="mi",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("distance"), "*", models.Value(Decimal("1609.344"))
+                        ),
+                    ),
+                    models.When(
+                        distance_unit="ft",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("distance"), "*", models.Value(Decimal("0.3048"))
+                        ),
+                    ),
+                    models.When(
+                        distance_unit="in",
+                        then=django.db.models.expressions.CombinedExpression(
+                            models.F("distance"), "*", models.Value(Decimal("0.0254"))
+                        ),
+                    ),
+                ),
+                null=True,
+                output_field=models.DecimalField(
+                    blank=True, decimal_places=4, max_digits=13, null=True
+                ),
+            ),
+        ),
+    ]

+ 26 - 0
netbox/wireless/tests/test_models.py

@@ -6,9 +6,12 @@ from django.test import RequestFactory, TestCase
 from core.models import ObjectChange
 from dcim.choices import InterfaceTypeChoices
 from dcim.models import Interface, Region, Site, SiteGroup
+from netbox.choices import DistanceUnitChoices
 from netbox.context_managers import event_tracking
 from users.models import User
+from utilities.conversion import to_meters
 from utilities.testing import create_test_device
+from utilities.testing.mixins import NormalizedMeasurementTestMixin
 from wireless.models import WirelessLAN, WirelessLink
 
 
@@ -106,3 +109,26 @@ class WirelessLinkTestCase(TestCase):
                 changed_object_id__in=(interface_a.pk, interface_b.pk),
             ).exists()
         )
+
+
+class WirelessLinkDistanceTestCase(NormalizedMeasurementTestMixin, TestCase):
+    model = WirelessLink
+    value_field = 'distance'
+    unit_field = 'distance_unit'
+    normalized_field = '_abs_distance'
+    unit_choices = DistanceUnitChoices
+    converter = staticmethod(to_meters)
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.device = create_test_device('Device 1')
+
+    def create_instance(self, **attrs):
+        index = next(self.sequence)
+        interface_a = Interface.objects.create(
+            device=self.device, name=f'Wireless {index}A', type=InterfaceTypeChoices.TYPE_80211A
+        )
+        interface_b = Interface.objects.create(
+            device=self.device, name=f'Wireless {index}B', type=InterfaceTypeChoices.TYPE_80211A
+        )
+        return WirelessLink(interface_a=interface_a, interface_b=interface_b, **attrs)