diff --git a/netbox_custom_objects/field_types.py b/netbox_custom_objects/field_types.py index f88bd533..f58f4492 100644 --- a/netbox_custom_objects/field_types.py +++ b/netbox_custom_objects/field_types.py @@ -392,7 +392,7 @@ 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) + 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/migrations/0016_widen_integer_columns.py b/netbox_custom_objects/migrations/0016_widen_integer_columns.py new file mode 100644 index 00000000..9e208eb5 --- /dev/null +++ b/netbox_custom_objects/migrations/0016_widen_integer_columns.py @@ -0,0 +1,67 @@ +""" +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(): + # 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 {schema_editor.quote_name(table_name)} " + f"ALTER COLUMN {schema_editor.quote_name(column_name)} TYPE bigint" + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("netbox_custom_objects", "0015_customobjecttype_config_context_enabled"), + ] + + operations = [ + migrations.RunPython(widen_integer_columns, migrations.RunPython.noop), + ] diff --git a/netbox_custom_objects/tests/test_field_types.py b/netbox_custom_objects/tests/test_field_types.py index 82277c13..9bf5c59d 100644 --- a/netbox_custom_objects/tests/test_field_types.py +++ b/netbox_custom_objects/tests/test_field_types.py @@ -1,11 +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, timezone from decimal import Decimal +from django.apps import apps from django.core.exceptions import FieldDoesNotExist, ValidationError +from django.db import connection, models from django.test import TestCase from core.models import ObjectType @@ -189,6 +192,89 @@ 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) + + 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. + # 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.0016_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."""