Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion netbox_custom_objects/field_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
67 changes: 67 additions & 0 deletions netbox_custom_objects/migrations/0016_widen_integer_columns.py
Original file line number Diff line number Diff line change
@@ -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),
]
86 changes: 86 additions & 0 deletions netbox_custom_objects/tests/test_field_types.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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."""
Expand Down