From eb06183269367ba6808f4302820c7d5696a4cf4e Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 29 Jun 2026 10:55:18 -0700 Subject: [PATCH 1/6] #532 - move integer BigIntegerField --- netbox_custom_objects/field_types.py | 5 +++- .../tests/test_field_types.py | 23 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/netbox_custom_objects/field_types.py b/netbox_custom_objects/field_types.py index 0de2bf65..0f70e556 100644 --- a/netbox_custom_objects/field_types.py +++ b/netbox_custom_objects/field_types.py @@ -366,7 +366,10 @@ def get_model_field(self, field, **kwargs): # TODO: handle all args for IntegerField field_kwargs = self._safe_kwargs(**kwargs) field_kwargs.update({"default": field.default, "unique": field.unique}) - return models.IntegerField(null=True, blank=True, **field_kwargs) + # Use a 64-bit column (bigint) so values are not capped at the 32-bit + # signed range. Matches NetBox core's convention for semantic integers + # (e.g. ASNField, L2VPN identifier). See issue #532. + return models.BigIntegerField(null=True, blank=True, **field_kwargs) def get_filterform_field(self, field, **kwargs): return forms.IntegerField( diff --git a/netbox_custom_objects/tests/test_field_types.py b/netbox_custom_objects/tests/test_field_types.py index 1fdb734e..96e77eef 100644 --- a/netbox_custom_objects/tests/test_field_types.py +++ b/netbox_custom_objects/tests/test_field_types.py @@ -6,6 +6,7 @@ from datetime import date, datetime from decimal import Decimal from django.core.exceptions import FieldDoesNotExist, ValidationError +from django.db import models from django.test import TestCase from core.models import ObjectType @@ -189,6 +190,28 @@ def test_integer_field_model_generation(self): self.assertEqual(instance.count, 25) + def test_integer_field_is_64_bit(self): + """Integer fields use a 64-bit (bigint) column, not 32-bit (issue #532).""" + self.create_custom_object_type_field( + self.custom_object_type, + name="count", + label="Count", + type="integer", + ) + + model = self.custom_object_type.get_model() + + # The generated model field must be a BigIntegerField. + self.assertIsInstance( + model._meta.get_field("count"), models.BigIntegerField + ) + + # A value beyond the signed 32-bit range must round-trip through the DB. + big_value = 9_000_000_000 # > 2**31 - 1 (2_147_483_647) + instance = model.objects.create(name="Test", count=big_value) + instance.refresh_from_db() + self.assertEqual(instance.count, big_value) + class DecimalFieldTypeTestCase(FieldTypeTestCase): """Test cases for decimal field type.""" From 059323cdbc5a2ff859f44c041e0002c9819c9049 Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 29 Jun 2026 10:56:28 -0700 Subject: [PATCH 2/6] #532 - move integer BigIntegerField --- netbox_custom_objects/field_types.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/netbox_custom_objects/field_types.py b/netbox_custom_objects/field_types.py index 0f70e556..4a46028e 100644 --- a/netbox_custom_objects/field_types.py +++ b/netbox_custom_objects/field_types.py @@ -366,9 +366,6 @@ def get_model_field(self, field, **kwargs): # TODO: handle all args for IntegerField field_kwargs = self._safe_kwargs(**kwargs) field_kwargs.update({"default": field.default, "unique": field.unique}) - # Use a 64-bit column (bigint) so values are not capped at the 32-bit - # signed range. Matches NetBox core's convention for semantic integers - # (e.g. ASNField, L2VPN identifier). See issue #532. return models.BigIntegerField(null=True, blank=True, **field_kwargs) def get_filterform_field(self, field, **kwargs): From 07c7ffa5680f0f114677d32137cb7b1fef4ace22 Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 29 Jun 2026 11:58:06 -0700 Subject: [PATCH 3/6] add migration --- .../migrations/0015_widen_integer_columns.py | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 netbox_custom_objects/migrations/0015_widen_integer_columns.py diff --git a/netbox_custom_objects/migrations/0015_widen_integer_columns.py b/netbox_custom_objects/migrations/0015_widen_integer_columns.py new file mode 100644 index 00000000..c83990e6 --- /dev/null +++ b/netbox_custom_objects/migrations/0015_widen_integer_columns.py @@ -0,0 +1,64 @@ +""" +Widen existing integer custom-object columns from 32-bit to 64-bit (bigint). + +Integer fields previously mapped to Django's IntegerField, i.e. a 32-bit signed +PostgreSQL ``integer`` column (max 2_147_483_647). They now map to BigIntegerField +(``bigint``). New columns are created as bigint automatically by the schema editor, +but columns on already-created custom_objects_* tables must be widened in place. + +``integer -> bigint`` is a lossless widening (every int32 value fits in int64), so +no USING clause or data transformation is needed. The conversion rewrites the table +under an ACCESS EXCLUSIVE lock; this is fine for typical custom-object table sizes. + +The reverse is intentionally a no-op: narrowing bigint back to integer could fail or +lose data for any value outside the 32-bit range. + +See issue #532. +""" + +from django.db import migrations + + +def widen_integer_columns(apps, schema_editor): + """ALTER every integer-typed custom-object column to bigint, in place.""" + CustomObjectTypeField = apps.get_model("netbox_custom_objects", "CustomObjectTypeField") + + # Drive off field metadata (not blind introspection of every custom_objects_* + # column) so we only touch user integer fields, never base-model columns + # inherited from NetBox mixins. After migration 0014 all field names are + # lowercase and the scalar column name equals the field name exactly. + integer_fields = CustomObjectTypeField.objects.filter(type="integer") + + with schema_editor.connection.cursor() as cursor: + for field in integer_fields: + table_name = f"custom_objects_{field.custom_object_type_id}" + column_name = field.name + + # Idempotent + safe: only act on a column that exists and is still a + # 32-bit integer. A no-op on fresh installs (already bigint) and on + # partial re-runs. + cursor.execute( + """ + SELECT 1 FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = %s + AND column_name = %s + AND data_type = 'integer' + """, + [table_name, column_name], + ) + if cursor.fetchone(): + cursor.execute( + f'ALTER TABLE "{table_name}" ALTER COLUMN "{column_name}" TYPE bigint' + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("netbox_custom_objects", "0014_fix_mixed_case_field_names"), + ] + + operations = [ + migrations.RunPython(widen_integer_columns, migrations.RunPython.noop), + ] From 1f515f9ef4e0a0e2fc7265e0c77cc950a8afec48 Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 29 Jun 2026 12:51:48 -0700 Subject: [PATCH 4/6] add test --- .../tests/test_field_types.py | 61 ++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/netbox_custom_objects/tests/test_field_types.py b/netbox_custom_objects/tests/test_field_types.py index 96e77eef..430befd9 100644 --- a/netbox_custom_objects/tests/test_field_types.py +++ b/netbox_custom_objects/tests/test_field_types.py @@ -1,12 +1,14 @@ """ Tests for all the different field types supported by Custom Object Type Fields. """ +from importlib import import_module from unittest import skip from unittest.mock import Mock from datetime import date, datetime from decimal import Decimal +from django.apps import apps from django.core.exceptions import FieldDoesNotExist, ValidationError -from django.db import models +from django.db import connection, models from django.test import TestCase from core.models import ObjectType @@ -212,6 +214,63 @@ def test_integer_field_is_64_bit(self): instance.refresh_from_db() self.assertEqual(instance.count, big_value) + def test_integer_field_upgrade_widens_existing_32bit_column(self): + """The 0015 migration widens pre-#532 32-bit integer columns to bigint. + + New tables already get a bigint column (test_integer_field_is_64_bit), + but custom_objects_* tables created before issue #532 have a 32-bit + ``integer`` column that the field-type change alone does not alter -- + they are managed=False. This exercises that upgrade path: realize the + column, force it back to 32-bit to mimic a legacy install, run the + migration's data function, and confirm it is widened in place. + """ + field = self.create_custom_object_type_field( + self.custom_object_type, + name="legacy_count", + label="Legacy Count", + type="integer", + ) + + # Realize the backing model/table/column. + model = self.custom_object_type.get_model() + + table_name = f"custom_objects_{self.custom_object_type.pk}" + column_name = field.name + + def column_data_type(): + with connection.cursor() as cursor: + cursor.execute( + """ + SELECT data_type FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = %s AND column_name = %s + """, + [table_name, column_name], + ) + row = cursor.fetchone() + return row[0] if row else None + + # Mimic a legacy install: force the column back to a 32-bit integer. + with connection.cursor() as cursor: + cursor.execute( + f'ALTER TABLE "{table_name}" ALTER COLUMN "{column_name}" TYPE integer' + ) + self.assertEqual(column_data_type(), "integer") + + # Run the migration's data function against the legacy column. + migration = import_module( + "netbox_custom_objects.migrations.0015_widen_integer_columns" + ) + with connection.schema_editor() as schema_editor: + migration.widen_integer_columns(apps, schema_editor) + + # Column is widened, and a value beyond the 32-bit range round-trips. + self.assertEqual(column_data_type(), "bigint") + big_value = 9_000_000_000 # > 2**31 - 1 (2_147_483_647) + instance = model.objects.create(name="Test", legacy_count=big_value) + instance.refresh_from_db() + self.assertEqual(instance.legacy_count, big_value) + class DecimalFieldTypeTestCase(FieldTypeTestCase): """Test cases for decimal field type.""" From 25ca383d6fff6dda3fa5b270801075d0c8aea84b Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 29 Jun 2026 14:07:28 -0700 Subject: [PATCH 5/6] fixes --- .../migrations/0015_widen_integer_columns.py | 5 ++++- netbox_custom_objects/tests/test_field_types.py | 4 ++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/netbox_custom_objects/migrations/0015_widen_integer_columns.py b/netbox_custom_objects/migrations/0015_widen_integer_columns.py index c83990e6..4a6e0b59 100644 --- a/netbox_custom_objects/migrations/0015_widen_integer_columns.py +++ b/netbox_custom_objects/migrations/0015_widen_integer_columns.py @@ -48,8 +48,11 @@ def widen_integer_columns(apps, schema_editor): [table_name, column_name], ) if cursor.fetchone(): + # quote_name() both identifiers (consistent with the %s-parameterised + # check above) so a field name containing a quote can't break out. cursor.execute( - f'ALTER TABLE "{table_name}" ALTER COLUMN "{column_name}" TYPE bigint' + f"ALTER TABLE {schema_editor.quote_name(table_name)} " + f"ALTER COLUMN {schema_editor.quote_name(column_name)} TYPE bigint" ) diff --git a/netbox_custom_objects/tests/test_field_types.py b/netbox_custom_objects/tests/test_field_types.py index 430befd9..f331bc33 100644 --- a/netbox_custom_objects/tests/test_field_types.py +++ b/netbox_custom_objects/tests/test_field_types.py @@ -258,6 +258,10 @@ def column_data_type(): self.assertEqual(column_data_type(), "integer") # Run the migration's data function against the legacy column. + # widen_integer_columns only reads stable CustomObjectTypeField columns + # (type, custom_object_type_id, name), so the current app registry is + # equivalent to the historical one RunPython would pass. If the function + # ever introspects historical field structure, switch to MigrationLoader. migration = import_module( "netbox_custom_objects.migrations.0015_widen_integer_columns" ) From 1c9b8e1428ed4dbe8f216cec8589a0825ee31da6 Mon Sep 17 00:00:00 2001 From: Arthur Date: Thu, 2 Jul 2026 12:01:27 -0700 Subject: [PATCH 6/6] merge feature --- ...5_widen_integer_columns.py => 0016_widen_integer_columns.py} | 2 +- netbox_custom_objects/tests/test_field_types.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename netbox_custom_objects/migrations/{0015_widen_integer_columns.py => 0016_widen_integer_columns.py} (97%) diff --git a/netbox_custom_objects/migrations/0015_widen_integer_columns.py b/netbox_custom_objects/migrations/0016_widen_integer_columns.py similarity index 97% rename from netbox_custom_objects/migrations/0015_widen_integer_columns.py rename to netbox_custom_objects/migrations/0016_widen_integer_columns.py index 4a6e0b59..9e208eb5 100644 --- a/netbox_custom_objects/migrations/0015_widen_integer_columns.py +++ b/netbox_custom_objects/migrations/0016_widen_integer_columns.py @@ -59,7 +59,7 @@ def widen_integer_columns(apps, schema_editor): class Migration(migrations.Migration): dependencies = [ - ("netbox_custom_objects", "0014_fix_mixed_case_field_names"), + ("netbox_custom_objects", "0015_customobjecttype_config_context_enabled"), ] operations = [ diff --git a/netbox_custom_objects/tests/test_field_types.py b/netbox_custom_objects/tests/test_field_types.py index de2f3810..9bf5c59d 100644 --- a/netbox_custom_objects/tests/test_field_types.py +++ b/netbox_custom_objects/tests/test_field_types.py @@ -263,7 +263,7 @@ def column_data_type(): # equivalent to the historical one RunPython would pass. If the function # ever introspects historical field structure, switch to MigrationLoader. migration = import_module( - "netbox_custom_objects.migrations.0015_widen_integer_columns" + "netbox_custom_objects.migrations.0016_widen_integer_columns" ) with connection.schema_editor() as schema_editor: migration.widen_integer_columns(apps, schema_editor)