Skip to content

Commit 89f6ca2

Browse files
committed
add migration and optimize fk constraint setup
1 parent 0853543 commit 89f6ca2

File tree

2 files changed

+129
-46
lines changed

2 files changed

+129
-46
lines changed
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# Generated migration to ensure FK constraints for existing OBJECT fields
2+
3+
from django.db import migrations
4+
5+
6+
def ensure_existing_fk_constraints(apps, schema_editor):
7+
"""
8+
Go through all existing CustomObjectType models and ensure FK constraints
9+
are properly set for any OBJECT type fields.
10+
11+
This is needed because the _ensure_fk_constraints method was refactored to work
12+
on individual fields rather than all fields, and this migration ensures existing
13+
fields have proper CASCADE constraints.
14+
"""
15+
CustomObjectType = apps.get_model('netbox_custom_objects', 'CustomObjectType')
16+
17+
for custom_object_type in CustomObjectType.objects.all():
18+
try:
19+
# Get the dynamically generated model for this CustomObjectType
20+
model = custom_object_type.get_model()
21+
22+
# Use the _ensure_all_fk_constraints method which processes all OBJECT fields
23+
# This method is kept specifically for migration purposes
24+
custom_object_type._ensure_all_fk_constraints(model)
25+
except Exception as e:
26+
# Log but don't fail the migration if a specific type has issues
27+
print(f"Warning: Could not ensure FK constraints for {custom_object_type}: {e}")
28+
29+
30+
class Migration(migrations.Migration):
31+
32+
dependencies = [
33+
('netbox_custom_objects', '0001_initial'),
34+
]
35+
36+
operations = [
37+
migrations.RunPython(
38+
ensure_existing_fk_constraints,
39+
reverse_code=migrations.RunPython.noop
40+
),
41+
]

netbox_custom_objects/models.py

Lines changed: 88 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -545,59 +545,73 @@ def get_model_with_serializer(self):
545545
self.register_custom_object_search_index(model)
546546
return model
547547

548-
def _ensure_fk_constraints(self, model):
548+
def _ensure_field_fk_constraint(self, model, field_name):
549549
"""
550-
Ensure that foreign key constraints are properly created at the database level
551-
for OBJECT type fields with ON DELETE CASCADE. This is necessary because models
552-
are created with managed=False, which may not properly create FK constraints
550+
Ensure that a foreign key constraint is properly created at the database level
551+
for a specific OBJECT type field with ON DELETE CASCADE. This is necessary because
552+
models are created with managed=False, which may not properly create FK constraints
553553
with CASCADE behavior.
554554
555-
:param model: The model to ensure FK constraints for
555+
:param model: The model containing the field
556+
:param field_name: The name of the field to ensure FK constraint for
556557
"""
557-
# Query all OBJECT type fields for this CustomObjectType
558-
object_fields = self.fields.filter(type=CustomFieldTypeChoices.TYPE_OBJECT)
558+
table_name = self.get_database_table_name()
559+
560+
# Get the model field
561+
try:
562+
model_field = model._meta.get_field(field_name)
563+
except Exception:
564+
return
559565

560-
if not object_fields.exists():
566+
if not (hasattr(model_field, 'remote_field') and model_field.remote_field):
561567
return
562568

563-
table_name = self.get_database_table_name()
569+
# Get the referenced table
570+
related_model = model_field.remote_field.model
571+
related_table = related_model._meta.db_table
572+
column_name = model_field.column
564573

565574
with connection.cursor() as cursor:
566-
for field in object_fields:
567-
field_name = field.name
568-
model_field = model._meta.get_field(field_name)
569-
if not (hasattr(model_field, 'remote_field') and model_field.remote_field):
570-
continue
575+
# Drop existing FK constraint if it exists
576+
# Query for existing constraints
577+
cursor.execute("""
578+
SELECT constraint_name
579+
FROM information_schema.table_constraints
580+
WHERE table_name = %s
581+
AND constraint_type = 'FOREIGN KEY'
582+
AND constraint_name LIKE %s
583+
""", [table_name, f"%{column_name}%"])
584+
585+
for row in cursor.fetchall():
586+
constraint_name = row[0]
587+
cursor.execute(f'ALTER TABLE "{table_name}" DROP CONSTRAINT IF EXISTS "{constraint_name}"')
588+
589+
# Create new FK constraint with ON DELETE CASCADE
590+
constraint_name = f"{table_name}_{column_name}_fk_cascade"
591+
cursor.execute(f"""
592+
ALTER TABLE "{table_name}"
593+
ADD CONSTRAINT "{constraint_name}"
594+
FOREIGN KEY ("{column_name}")
595+
REFERENCES "{related_table}" ("id")
596+
ON DELETE CASCADE
597+
DEFERRABLE INITIALLY DEFERRED
598+
""")
599+
600+
def _ensure_all_fk_constraints(self, model):
601+
"""
602+
Ensure that foreign key constraints are properly created at the database level
603+
for ALL OBJECT type fields with ON DELETE CASCADE.
571604
572-
# Get the referenced table
573-
related_model = model_field.remote_field.model
574-
related_table = related_model._meta.db_table
575-
column_name = model_field.column
576-
577-
# Drop existing FK constraint if it exists
578-
# Query for existing constraints
579-
cursor.execute("""
580-
SELECT constraint_name
581-
FROM information_schema.table_constraints
582-
WHERE table_name = %s
583-
AND constraint_type = 'FOREIGN KEY'
584-
AND constraint_name LIKE %s
585-
""", [table_name, f"%{column_name}%"])
586-
587-
for row in cursor.fetchall():
588-
constraint_name = row[0]
589-
cursor.execute(f'ALTER TABLE "{table_name}" DROP CONSTRAINT IF EXISTS "{constraint_name}"')
590-
591-
# Create new FK constraint with ON DELETE CASCADE
592-
constraint_name = f"{table_name}_{column_name}_fk_cascade"
593-
cursor.execute(f"""
594-
ALTER TABLE "{table_name}"
595-
ADD CONSTRAINT "{constraint_name}"
596-
FOREIGN KEY ("{column_name}")
597-
REFERENCES "{related_table}" ("id")
598-
ON DELETE CASCADE
599-
DEFERRABLE INITIALLY DEFERRED
600-
""")
605+
NOTE: This method is deprecated for normal use - use _ensure_field_fk_constraint
606+
for individual fields. This is kept for migration purposes only.
607+
608+
:param model: The model to ensure FK constraints for
609+
"""
610+
# Query all OBJECT type fields for this CustomObjectType
611+
object_fields = self.fields.filter(type=CustomFieldTypeChoices.TYPE_OBJECT)
612+
613+
for field in object_fields:
614+
self._ensure_field_fk_constraint(model, field.name)
601615

602616
def create_model(self):
603617
from netbox_custom_objects.api.serializers import get_serializer_class
@@ -615,8 +629,8 @@ def create_model(self):
615629
with connection.schema_editor() as schema_editor:
616630
schema_editor.create_model(model)
617631

618-
# Ensure FK constraints are properly created for OBJECT fields
619-
self._ensure_fk_constraints(model)
632+
# Note: FK constraints for OBJECT fields are now created when the field is saved,
633+
# not when the model is first created (since there are no fields yet)
620634

621635
get_serializer_class(model)
622636
self.register_custom_object_search_index(model)
@@ -855,6 +869,8 @@ def __init__(self, *args, **kwargs):
855869
super().__init__(*args, **kwargs)
856870
self._name = self.__dict__.get("name")
857871
self._original_name = self.name
872+
self._original_type = self.type
873+
self._original_related_object_type_id = self.related_object_type_id
858874

859875
def __str__(self):
860876
return self.label or self.name.replace("_", " ").capitalize()
@@ -1542,14 +1558,40 @@ def save(self, *args, **kwargs):
15421558
schema_editor.alter_field(model, old_field, model_field)
15431559

15441560
# Ensure FK constraints are properly created for OBJECT fields with CASCADE behavior
1561+
# Only do this when:
1562+
# 1. Creating a new OBJECT field, OR
1563+
# 2. Updating an existing field where type changed to OBJECT, OR
1564+
# 3. Updating an existing OBJECT field where related_object_type changed
1565+
should_ensure_fk = False
15451566
if self.type == CustomFieldTypeChoices.TYPE_OBJECT:
1546-
self.custom_object_type._ensure_fk_constraints(model)
1567+
if self._state.adding:
1568+
# New OBJECT field - ensure FK constraint
1569+
should_ensure_fk = True
1570+
else:
1571+
# Existing field - check if type changed to OBJECT or related_object_type changed
1572+
type_changed_to_object = (
1573+
self._original_type != CustomFieldTypeChoices.TYPE_OBJECT
1574+
and self.type == CustomFieldTypeChoices.TYPE_OBJECT
1575+
)
1576+
related_object_changed = (
1577+
self._original_type == CustomFieldTypeChoices.TYPE_OBJECT
1578+
and self.related_object_type_id != self._original_related_object_type_id
1579+
)
1580+
should_ensure_fk = type_changed_to_object or related_object_changed
15471581

15481582
# Clear and refresh the model cache for this CustomObjectType when a field is modified
15491583
self.custom_object_type.clear_model_cache(self.custom_object_type.id)
15501584

15511585
super().save(*args, **kwargs)
15521586

1587+
# Ensure FK constraints AFTER the transaction commits to avoid "pending trigger events" errors
1588+
# We use transaction.on_commit() to ensure all deferred constraints are checked first
1589+
if should_ensure_fk:
1590+
def ensure_constraint():
1591+
self.custom_object_type._ensure_field_fk_constraint(model, self.name)
1592+
1593+
transaction.on_commit(ensure_constraint)
1594+
15531595
# Reregister SearchIndex with new set of searchable fields
15541596
self.custom_object_type.register_custom_object_search_index(model)
15551597

0 commit comments

Comments
 (0)