From 41ac3255fb00208f08c93040cc5b746c29689564 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20An=C3=ADcio?= Date: Mon, 30 Mar 2026 13:23:50 -0300 Subject: [PATCH 1/3] hotfix: user with follows deletion cascade --- .../0009_add_db_cascade_to_documentid_fks.py | 107 +++++++++++++++++ .../tests/test_user_deletion_cascade.py | 50 ++++++++ .../0003_add_db_cascade_to_documentid_fks.py | 109 ++++++++++++++++++ 3 files changed, 266 insertions(+) create mode 100644 baseapp_follows/migrations/0009_add_db_cascade_to_documentid_fks.py create mode 100644 baseapp_follows/tests/test_user_deletion_cascade.py create mode 100644 testproject/follows/migrations/0003_add_db_cascade_to_documentid_fks.py diff --git a/baseapp_follows/migrations/0009_add_db_cascade_to_documentid_fks.py b/baseapp_follows/migrations/0009_add_db_cascade_to_documentid_fks.py new file mode 100644 index 00000000..3baeb9d3 --- /dev/null +++ b/baseapp_follows/migrations/0009_add_db_cascade_to_documentid_fks.py @@ -0,0 +1,107 @@ +from django.db import migrations + +DOCUMENTID_TABLE = "baseapp_core_documentid" + + +def _get_fk_constraints_referencing_documentid(cursor, table, column): + """Find FK constraint names on (table, column) that reference baseapp_core_documentid.""" + cursor.execute( + """ + SELECT tc.constraint_name + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_name = kcu.table_name + JOIN information_schema.constraint_column_usage ccu + ON tc.constraint_name = ccu.constraint_name + WHERE tc.table_name = %s + AND kcu.column_name = %s + AND tc.constraint_type = 'FOREIGN KEY' + AND ccu.table_name = %s + """, + [table, column, DOCUMENTID_TABLE], + ) + return [row[0] for row in cursor.fetchall()] + + +def _replace_fk_constraint(schema_editor, cursor, table, column, on_delete_cascade): + """Drop and recreate a FK constraint with or without ON DELETE CASCADE.""" + constraint_names = _get_fk_constraints_referencing_documentid(cursor, table, column) + + if not constraint_names: + # FK doesn't reference DocumentId yet (e.g. swapped model migration hasn't run) + return + + for constraint_name in constraint_names: + schema_editor.execute( + "ALTER TABLE %s DROP CONSTRAINT %s" + % ( + schema_editor.quote_name(table), + schema_editor.quote_name(constraint_name), + ) + ) + + new_constraint_name = "%s_%s_fk_documentid" % (table, column) + if len(new_constraint_name) > 63: + new_constraint_name = new_constraint_name[:63] + + on_delete_clause = "ON DELETE CASCADE " if on_delete_cascade else "" + + schema_editor.execute( + "ALTER TABLE %s ADD CONSTRAINT %s " + "FOREIGN KEY (%s) REFERENCES %s (id) " + "%sDEFERRABLE INITIALLY DEFERRED" + % ( + schema_editor.quote_name(table), + schema_editor.quote_name(new_constraint_name), + schema_editor.quote_name(column), + schema_editor.quote_name(DOCUMENTID_TABLE), + on_delete_clause, + ) + ) + + +def add_db_level_cascade(apps, schema_editor): + """ + Add ON DELETE CASCADE at the database level for FollowStats FK to DocumentId. + + Django's on_delete=CASCADE only works at the ORM level. The DocumentIdMixin + uses pgtriggers that delete DocumentId rows via raw SQL, bypassing Django's + cascade. This migration makes the DB constraint match the intended behavior. + + Note: For swapped Follow models, the consumer app must create its own + migration to add CASCADE to the Follow table's FKs. + """ + FollowStats = apps.get_model("baseapp_follows", "FollowStats") + followstats_table = FollowStats._meta.db_table + + with schema_editor.connection.cursor() as cursor: + _replace_fk_constraint( + schema_editor, cursor, followstats_table, "target_id", on_delete_cascade=True + ) + + +def remove_db_level_cascade(apps, schema_editor): + """Reverse: restore FK constraint without ON DELETE CASCADE.""" + FollowStats = apps.get_model("baseapp_follows", "FollowStats") + followstats_table = FollowStats._meta.db_table + + with schema_editor.connection.cursor() as cursor: + _replace_fk_constraint( + schema_editor, cursor, followstats_table, "target_id", on_delete_cascade=False + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("baseapp_core", "0001_initial"), + ("baseapp_follows", "0008_create_followstats_remap_fks"), + ] + + operations = [ + migrations.RunPython( + add_db_level_cascade, + remove_db_level_cascade, + ), + ] diff --git a/baseapp_follows/tests/test_user_deletion_cascade.py b/baseapp_follows/tests/test_user_deletion_cascade.py new file mode 100644 index 00000000..f5a737a9 --- /dev/null +++ b/baseapp_follows/tests/test_user_deletion_cascade.py @@ -0,0 +1,50 @@ +import pytest +import swapper +from django.contrib.auth import get_user_model + +from baseapp_core.models import DocumentId +from baseapp_follows.models import FollowStats +from baseapp_profiles.tests.factories import ProfileFactory + +Follow = swapper.load_model("baseapp_follows", "Follow") +User = get_user_model() + +pytestmark = pytest.mark.django_db + + +def test_deleting_follower_user_cascades(): + """Deleting a user who follows someone should not raise IntegrityError.""" + profile1 = ProfileFactory() + profile2 = ProfileFactory() + + doc1 = DocumentId.get_or_create_for_object(profile1) + doc2 = DocumentId.get_or_create_for_object(profile2) + + Follow.objects.create(actor=doc1, target=doc2) + + assert FollowStats.objects.filter(target=doc1).exists() + assert FollowStats.objects.filter(target=doc2).exists() + assert Follow.objects.count() == 1 + + # Delete the follower's user — triggers pgtrigger DELETE on DocumentId + profile1.owner.delete() + + assert Follow.objects.count() == 0 + assert not FollowStats.objects.filter(target_id=doc1.pk).exists() + + +def test_deleting_followed_user_cascades(): + """Deleting a user who is being followed should not raise IntegrityError.""" + profile1 = ProfileFactory() + profile2 = ProfileFactory() + + doc1 = DocumentId.get_or_create_for_object(profile1) + doc2 = DocumentId.get_or_create_for_object(profile2) + + Follow.objects.create(actor=doc1, target=doc2) + + # Delete the followed user + profile2.owner.delete() + + assert Follow.objects.count() == 0 + assert not FollowStats.objects.filter(target_id=doc2.pk).exists() diff --git a/testproject/follows/migrations/0003_add_db_cascade_to_documentid_fks.py b/testproject/follows/migrations/0003_add_db_cascade_to_documentid_fks.py new file mode 100644 index 00000000..d40f0f77 --- /dev/null +++ b/testproject/follows/migrations/0003_add_db_cascade_to_documentid_fks.py @@ -0,0 +1,109 @@ +from django.db import migrations + +DOCUMENTID_TABLE = "baseapp_core_documentid" + + +def _get_fk_constraints_referencing_documentid(cursor, table, column): + """Find FK constraint names on (table, column) that reference baseapp_core_documentid.""" + cursor.execute( + """ + SELECT tc.constraint_name + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_name = kcu.table_name + JOIN information_schema.constraint_column_usage ccu + ON tc.constraint_name = ccu.constraint_name + WHERE tc.table_name = %s + AND kcu.column_name = %s + AND tc.constraint_type = 'FOREIGN KEY' + AND ccu.table_name = %s + """, + [table, column, DOCUMENTID_TABLE], + ) + return [row[0] for row in cursor.fetchall()] + + +def _replace_fk_constraint(schema_editor, cursor, table, column, on_delete_cascade): + """Drop and recreate a FK constraint with or without ON DELETE CASCADE.""" + constraint_names = _get_fk_constraints_referencing_documentid(cursor, table, column) + + if not constraint_names: + return + + for constraint_name in constraint_names: + schema_editor.execute( + "ALTER TABLE %s DROP CONSTRAINT %s" + % ( + schema_editor.quote_name(table), + schema_editor.quote_name(constraint_name), + ) + ) + + new_constraint_name = "%s_%s_fk_documentid" % (table, column) + if len(new_constraint_name) > 63: + new_constraint_name = new_constraint_name[:63] + + on_delete_clause = "ON DELETE CASCADE " if on_delete_cascade else "" + + schema_editor.execute( + "ALTER TABLE %s ADD CONSTRAINT %s " + "FOREIGN KEY (%s) REFERENCES %s (id) " + "%sDEFERRABLE INITIALLY DEFERRED" + % ( + schema_editor.quote_name(table), + schema_editor.quote_name(new_constraint_name), + schema_editor.quote_name(column), + schema_editor.quote_name(DOCUMENTID_TABLE), + on_delete_clause, + ) + ) + + +def add_db_level_cascade(apps, schema_editor): + """ + Add ON DELETE CASCADE at the database level for Follow FKs to DocumentId. + + Django's on_delete=CASCADE only works at the ORM level. The DocumentIdMixin + uses pgtriggers that delete DocumentId rows via raw SQL, bypassing Django's + cascade. This migration makes the DB constraints match the intended behavior. + """ + Follow = apps.get_model("follows", "Follow") + follow_table = Follow._meta.db_table + + with schema_editor.connection.cursor() as cursor: + _replace_fk_constraint( + schema_editor, cursor, follow_table, "actor_id", on_delete_cascade=True + ) + _replace_fk_constraint( + schema_editor, cursor, follow_table, "target_id", on_delete_cascade=True + ) + + +def remove_db_level_cascade(apps, schema_editor): + """Reverse: restore FK constraints without ON DELETE CASCADE.""" + Follow = apps.get_model("follows", "Follow") + follow_table = Follow._meta.db_table + + with schema_editor.connection.cursor() as cursor: + _replace_fk_constraint( + schema_editor, cursor, follow_table, "actor_id", on_delete_cascade=False + ) + _replace_fk_constraint( + schema_editor, cursor, follow_table, "target_id", on_delete_cascade=False + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("baseapp_core", "0001_initial"), + ("follows", "0002_alter_follow_actor_alter_follow_target"), + ] + + operations = [ + migrations.RunPython( + add_db_level_cascade, + remove_db_level_cascade, + ), + ] From 26f0e4c7327d44d8564b6449964d45ebeaeb0ca8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20An=C3=ADcio?= Date: Tue, 31 Mar 2026 17:34:03 -0300 Subject: [PATCH 2/3] chore: review changes --- baseapp_core/db_utils.py | 67 +++++++++ .../0009_add_db_cascade_to_documentid_fks.py | 131 ++++++++---------- .../0003_add_db_cascade_to_documentid_fks.py | 95 ++++--------- 3 files changed, 156 insertions(+), 137 deletions(-) create mode 100644 baseapp_core/db_utils.py diff --git a/baseapp_core/db_utils.py b/baseapp_core/db_utils.py new file mode 100644 index 00000000..30e7d00c --- /dev/null +++ b/baseapp_core/db_utils.py @@ -0,0 +1,67 @@ +DOCUMENTID_TABLE = "baseapp_core_documentid" + + +def get_fk_constraints_referencing_table(cursor, table, column, referenced_table): + """Find FK constraint names on (table, column) that reference the given table.""" + cursor.execute( + """ + SELECT tc.constraint_name + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_name = kcu.table_name + JOIN information_schema.constraint_column_usage ccu + ON tc.constraint_name = ccu.constraint_name + WHERE tc.table_name = %s + AND kcu.column_name = %s + AND tc.constraint_type = 'FOREIGN KEY' + AND ccu.table_name = %s + """, + [table, column, referenced_table], + ) + return [row[0] for row in cursor.fetchall()] + + +def replace_fk_constraint( + schema_editor, cursor, table, column, referenced_table, on_delete_cascade +): + """ + Drop and recreate a FK constraint with or without ON DELETE CASCADE. + + Raises RuntimeError if no FK constraint is found, since the migration + dependencies should guarantee the FK exists by the time this runs. + """ + constraint_names = get_fk_constraints_referencing_table(cursor, table, column, referenced_table) + + if not constraint_names: + raise RuntimeError( + f"Expected an FK from {table}.{column} to {referenced_table}, but none was found." + ) + + for constraint_name in constraint_names: + schema_editor.execute( + "ALTER TABLE %s DROP CONSTRAINT %s" + % ( + schema_editor.quote_name(table), + schema_editor.quote_name(constraint_name), + ) + ) + + new_constraint_name = "%s_%s_fk_%s" % (table, column, referenced_table) + if len(new_constraint_name) > 63: + new_constraint_name = new_constraint_name[:63] + + on_delete_clause = "ON DELETE CASCADE " if on_delete_cascade else "" + + schema_editor.execute( + "ALTER TABLE %s ADD CONSTRAINT %s " + "FOREIGN KEY (%s) REFERENCES %s (id) " + "%sDEFERRABLE INITIALLY DEFERRED" + % ( + schema_editor.quote_name(table), + schema_editor.quote_name(new_constraint_name), + schema_editor.quote_name(column), + schema_editor.quote_name(referenced_table), + on_delete_clause, + ) + ) diff --git a/baseapp_follows/migrations/0009_add_db_cascade_to_documentid_fks.py b/baseapp_follows/migrations/0009_add_db_cascade_to_documentid_fks.py index 3baeb9d3..18ff7b9c 100644 --- a/baseapp_follows/migrations/0009_add_db_cascade_to_documentid_fks.py +++ b/baseapp_follows/migrations/0009_add_db_cascade_to_documentid_fks.py @@ -1,102 +1,91 @@ +import swapper from django.db import migrations -DOCUMENTID_TABLE = "baseapp_core_documentid" - - -def _get_fk_constraints_referencing_documentid(cursor, table, column): - """Find FK constraint names on (table, column) that reference baseapp_core_documentid.""" - cursor.execute( - """ - SELECT tc.constraint_name - FROM information_schema.table_constraints tc - JOIN information_schema.key_column_usage kcu - ON tc.constraint_name = kcu.constraint_name - AND tc.table_name = kcu.table_name - JOIN information_schema.constraint_column_usage ccu - ON tc.constraint_name = ccu.constraint_name - WHERE tc.table_name = %s - AND kcu.column_name = %s - AND tc.constraint_type = 'FOREIGN KEY' - AND ccu.table_name = %s - """, - [table, column, DOCUMENTID_TABLE], - ) - return [row[0] for row in cursor.fetchall()] - - -def _replace_fk_constraint(schema_editor, cursor, table, column, on_delete_cascade): - """Drop and recreate a FK constraint with or without ON DELETE CASCADE.""" - constraint_names = _get_fk_constraints_referencing_documentid(cursor, table, column) - - if not constraint_names: - # FK doesn't reference DocumentId yet (e.g. swapped model migration hasn't run) - return - - for constraint_name in constraint_names: - schema_editor.execute( - "ALTER TABLE %s DROP CONSTRAINT %s" - % ( - schema_editor.quote_name(table), - schema_editor.quote_name(constraint_name), - ) - ) - - new_constraint_name = "%s_%s_fk_documentid" % (table, column) - if len(new_constraint_name) > 63: - new_constraint_name = new_constraint_name[:63] - - on_delete_clause = "ON DELETE CASCADE " if on_delete_cascade else "" - - schema_editor.execute( - "ALTER TABLE %s ADD CONSTRAINT %s " - "FOREIGN KEY (%s) REFERENCES %s (id) " - "%sDEFERRABLE INITIALLY DEFERRED" - % ( - schema_editor.quote_name(table), - schema_editor.quote_name(new_constraint_name), - schema_editor.quote_name(column), - schema_editor.quote_name(DOCUMENTID_TABLE), - on_delete_clause, - ) - ) +from baseapp_core.db_utils import DOCUMENTID_TABLE, replace_fk_constraint def add_db_level_cascade(apps, schema_editor): """ - Add ON DELETE CASCADE at the database level for FollowStats FK to DocumentId. + Add ON DELETE CASCADE at the database level for FK constraints pointing to DocumentId. Django's on_delete=CASCADE only works at the ORM level. The DocumentIdMixin uses pgtriggers that delete DocumentId rows via raw SQL, bypassing Django's - cascade. This migration makes the DB constraint match the intended behavior. - - Note: For swapped Follow models, the consumer app must create its own - migration to add CASCADE to the Follow table's FKs. + cascade. This migration makes the DB constraints match the intended behavior. """ FollowStats = apps.get_model("baseapp_follows", "FollowStats") - followstats_table = FollowStats._meta.db_table with schema_editor.connection.cursor() as cursor: - _replace_fk_constraint( - schema_editor, cursor, followstats_table, "target_id", on_delete_cascade=True + replace_fk_constraint( + schema_editor, + cursor, + FollowStats._meta.db_table, + "target_id", + DOCUMENTID_TABLE, + on_delete_cascade=True, ) + if not swapper.is_swapped("baseapp_follows", "Follow"): + Follow = apps.get_model("baseapp_follows", "Follow") + follow_table = Follow._meta.db_table + replace_fk_constraint( + schema_editor, + cursor, + follow_table, + "actor_id", + DOCUMENTID_TABLE, + on_delete_cascade=True, + ) + replace_fk_constraint( + schema_editor, + cursor, + follow_table, + "target_id", + DOCUMENTID_TABLE, + on_delete_cascade=True, + ) + def remove_db_level_cascade(apps, schema_editor): - """Reverse: restore FK constraint without ON DELETE CASCADE.""" + """Reverse: restore FK constraints without ON DELETE CASCADE.""" FollowStats = apps.get_model("baseapp_follows", "FollowStats") - followstats_table = FollowStats._meta.db_table with schema_editor.connection.cursor() as cursor: - _replace_fk_constraint( - schema_editor, cursor, followstats_table, "target_id", on_delete_cascade=False + replace_fk_constraint( + schema_editor, + cursor, + FollowStats._meta.db_table, + "target_id", + DOCUMENTID_TABLE, + on_delete_cascade=False, ) + if not swapper.is_swapped("baseapp_follows", "Follow"): + Follow = apps.get_model("baseapp_follows", "Follow") + follow_table = Follow._meta.db_table + replace_fk_constraint( + schema_editor, + cursor, + follow_table, + "actor_id", + DOCUMENTID_TABLE, + on_delete_cascade=False, + ) + replace_fk_constraint( + schema_editor, + cursor, + follow_table, + "target_id", + DOCUMENTID_TABLE, + on_delete_cascade=False, + ) + class Migration(migrations.Migration): dependencies = [ ("baseapp_core", "0001_initial"), ("baseapp_follows", "0008_create_followstats_remap_fks"), + swapper.dependency("baseapp_follows", "Follow"), ] operations = [ diff --git a/testproject/follows/migrations/0003_add_db_cascade_to_documentid_fks.py b/testproject/follows/migrations/0003_add_db_cascade_to_documentid_fks.py index d40f0f77..3c6304c1 100644 --- a/testproject/follows/migrations/0003_add_db_cascade_to_documentid_fks.py +++ b/testproject/follows/migrations/0003_add_db_cascade_to_documentid_fks.py @@ -1,63 +1,6 @@ from django.db import migrations -DOCUMENTID_TABLE = "baseapp_core_documentid" - - -def _get_fk_constraints_referencing_documentid(cursor, table, column): - """Find FK constraint names on (table, column) that reference baseapp_core_documentid.""" - cursor.execute( - """ - SELECT tc.constraint_name - FROM information_schema.table_constraints tc - JOIN information_schema.key_column_usage kcu - ON tc.constraint_name = kcu.constraint_name - AND tc.table_name = kcu.table_name - JOIN information_schema.constraint_column_usage ccu - ON tc.constraint_name = ccu.constraint_name - WHERE tc.table_name = %s - AND kcu.column_name = %s - AND tc.constraint_type = 'FOREIGN KEY' - AND ccu.table_name = %s - """, - [table, column, DOCUMENTID_TABLE], - ) - return [row[0] for row in cursor.fetchall()] - - -def _replace_fk_constraint(schema_editor, cursor, table, column, on_delete_cascade): - """Drop and recreate a FK constraint with or without ON DELETE CASCADE.""" - constraint_names = _get_fk_constraints_referencing_documentid(cursor, table, column) - - if not constraint_names: - return - - for constraint_name in constraint_names: - schema_editor.execute( - "ALTER TABLE %s DROP CONSTRAINT %s" - % ( - schema_editor.quote_name(table), - schema_editor.quote_name(constraint_name), - ) - ) - - new_constraint_name = "%s_%s_fk_documentid" % (table, column) - if len(new_constraint_name) > 63: - new_constraint_name = new_constraint_name[:63] - - on_delete_clause = "ON DELETE CASCADE " if on_delete_cascade else "" - - schema_editor.execute( - "ALTER TABLE %s ADD CONSTRAINT %s " - "FOREIGN KEY (%s) REFERENCES %s (id) " - "%sDEFERRABLE INITIALLY DEFERRED" - % ( - schema_editor.quote_name(table), - schema_editor.quote_name(new_constraint_name), - schema_editor.quote_name(column), - schema_editor.quote_name(DOCUMENTID_TABLE), - on_delete_clause, - ) - ) +from baseapp_core.db_utils import DOCUMENTID_TABLE, replace_fk_constraint def add_db_level_cascade(apps, schema_editor): @@ -72,11 +15,21 @@ def add_db_level_cascade(apps, schema_editor): follow_table = Follow._meta.db_table with schema_editor.connection.cursor() as cursor: - _replace_fk_constraint( - schema_editor, cursor, follow_table, "actor_id", on_delete_cascade=True + replace_fk_constraint( + schema_editor, + cursor, + follow_table, + "actor_id", + DOCUMENTID_TABLE, + on_delete_cascade=True, ) - _replace_fk_constraint( - schema_editor, cursor, follow_table, "target_id", on_delete_cascade=True + replace_fk_constraint( + schema_editor, + cursor, + follow_table, + "target_id", + DOCUMENTID_TABLE, + on_delete_cascade=True, ) @@ -86,11 +39,21 @@ def remove_db_level_cascade(apps, schema_editor): follow_table = Follow._meta.db_table with schema_editor.connection.cursor() as cursor: - _replace_fk_constraint( - schema_editor, cursor, follow_table, "actor_id", on_delete_cascade=False + replace_fk_constraint( + schema_editor, + cursor, + follow_table, + "actor_id", + DOCUMENTID_TABLE, + on_delete_cascade=False, ) - _replace_fk_constraint( - schema_editor, cursor, follow_table, "target_id", on_delete_cascade=False + replace_fk_constraint( + schema_editor, + cursor, + follow_table, + "target_id", + DOCUMENTID_TABLE, + on_delete_cascade=False, ) From 41dc0ac19c4724e858ace8db92c78356509a34f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20An=C3=ADcio?= Date: Tue, 31 Mar 2026 18:04:08 -0300 Subject: [PATCH 3/3] chore: review changes 2 --- baseapp_core/db_utils.py | 67 --------- .../0009_add_db_cascade_to_documentid_fks.py | 135 +++++++++++++----- .../0003_add_db_cascade_to_documentid_fks.py | 66 ++------- 3 files changed, 105 insertions(+), 163 deletions(-) delete mode 100644 baseapp_core/db_utils.py diff --git a/baseapp_core/db_utils.py b/baseapp_core/db_utils.py deleted file mode 100644 index 30e7d00c..00000000 --- a/baseapp_core/db_utils.py +++ /dev/null @@ -1,67 +0,0 @@ -DOCUMENTID_TABLE = "baseapp_core_documentid" - - -def get_fk_constraints_referencing_table(cursor, table, column, referenced_table): - """Find FK constraint names on (table, column) that reference the given table.""" - cursor.execute( - """ - SELECT tc.constraint_name - FROM information_schema.table_constraints tc - JOIN information_schema.key_column_usage kcu - ON tc.constraint_name = kcu.constraint_name - AND tc.table_name = kcu.table_name - JOIN information_schema.constraint_column_usage ccu - ON tc.constraint_name = ccu.constraint_name - WHERE tc.table_name = %s - AND kcu.column_name = %s - AND tc.constraint_type = 'FOREIGN KEY' - AND ccu.table_name = %s - """, - [table, column, referenced_table], - ) - return [row[0] for row in cursor.fetchall()] - - -def replace_fk_constraint( - schema_editor, cursor, table, column, referenced_table, on_delete_cascade -): - """ - Drop and recreate a FK constraint with or without ON DELETE CASCADE. - - Raises RuntimeError if no FK constraint is found, since the migration - dependencies should guarantee the FK exists by the time this runs. - """ - constraint_names = get_fk_constraints_referencing_table(cursor, table, column, referenced_table) - - if not constraint_names: - raise RuntimeError( - f"Expected an FK from {table}.{column} to {referenced_table}, but none was found." - ) - - for constraint_name in constraint_names: - schema_editor.execute( - "ALTER TABLE %s DROP CONSTRAINT %s" - % ( - schema_editor.quote_name(table), - schema_editor.quote_name(constraint_name), - ) - ) - - new_constraint_name = "%s_%s_fk_%s" % (table, column, referenced_table) - if len(new_constraint_name) > 63: - new_constraint_name = new_constraint_name[:63] - - on_delete_clause = "ON DELETE CASCADE " if on_delete_cascade else "" - - schema_editor.execute( - "ALTER TABLE %s ADD CONSTRAINT %s " - "FOREIGN KEY (%s) REFERENCES %s (id) " - "%sDEFERRABLE INITIALLY DEFERRED" - % ( - schema_editor.quote_name(table), - schema_editor.quote_name(new_constraint_name), - schema_editor.quote_name(column), - schema_editor.quote_name(referenced_table), - on_delete_clause, - ) - ) diff --git a/baseapp_follows/migrations/0009_add_db_cascade_to_documentid_fks.py b/baseapp_follows/migrations/0009_add_db_cascade_to_documentid_fks.py index 18ff7b9c..74fe4712 100644 --- a/baseapp_follows/migrations/0009_add_db_cascade_to_documentid_fks.py +++ b/baseapp_follows/migrations/0009_add_db_cascade_to_documentid_fks.py @@ -1,7 +1,98 @@ import swapper from django.db import migrations -from baseapp_core.db_utils import DOCUMENTID_TABLE, replace_fk_constraint +from baseapp_core.swappable import get_apps_model + +DOCUMENTID_TABLE = "baseapp_core_documentid" + + +def get_fk_constraints_referencing_table(cursor, table, column, referenced_table): + """Find FK constraint names on (table, column) that reference the given table.""" + cursor.execute( + """ + SELECT tc.constraint_name + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_name = kcu.table_name + JOIN information_schema.constraint_column_usage ccu + ON tc.constraint_name = ccu.constraint_name + WHERE tc.table_name = %s + AND kcu.column_name = %s + AND tc.constraint_type = 'FOREIGN KEY' + AND ccu.table_name = %s + """, + [table, column, referenced_table], + ) + return [row[0] for row in cursor.fetchall()] + + +def replace_fk_constraint( + schema_editor, cursor, table, column, referenced_table, on_delete_cascade +): + """ + Drop and recreate a FK constraint with or without ON DELETE CASCADE. + + Raises RuntimeError if no FK constraint is found, since the migration + dependencies should guarantee the FK exists by the time this runs. + """ + constraint_names = get_fk_constraints_referencing_table(cursor, table, column, referenced_table) + + if not constraint_names: + raise RuntimeError( + f"Expected an FK from {table}.{column} to {referenced_table}, but none was found." + ) + + for constraint_name in constraint_names: + schema_editor.execute( + "ALTER TABLE %s DROP CONSTRAINT %s" + % ( + schema_editor.quote_name(table), + schema_editor.quote_name(constraint_name), + ) + ) + + new_constraint_name = "%s_%s_fk_%s" % (table, column, referenced_table) + if len(new_constraint_name) > 63: + new_constraint_name = new_constraint_name[:63] + + on_delete_clause = "ON DELETE CASCADE " if on_delete_cascade else "" + + schema_editor.execute( + "ALTER TABLE %s ADD CONSTRAINT %s " + "FOREIGN KEY (%s) REFERENCES %s (id) " + "%sDEFERRABLE INITIALLY DEFERRED" + % ( + schema_editor.quote_name(table), + schema_editor.quote_name(new_constraint_name), + schema_editor.quote_name(column), + schema_editor.quote_name(referenced_table), + on_delete_clause, + ) + ) + + +def _replace_follow_fks(apps, schema_editor, on_delete_cascade): + Follow = get_apps_model(apps, "baseapp_follows", "Follow") + follow_table = Follow._meta.db_table + + with schema_editor.connection.cursor() as cursor: + replace_fk_constraint( + schema_editor, + cursor, + follow_table, + "actor_id", + DOCUMENTID_TABLE, + on_delete_cascade, + ) + replace_fk_constraint( + schema_editor, + cursor, + follow_table, + "target_id", + DOCUMENTID_TABLE, + on_delete_cascade, + ) def add_db_level_cascade(apps, schema_editor): @@ -24,25 +115,8 @@ def add_db_level_cascade(apps, schema_editor): on_delete_cascade=True, ) - if not swapper.is_swapped("baseapp_follows", "Follow"): - Follow = apps.get_model("baseapp_follows", "Follow") - follow_table = Follow._meta.db_table - replace_fk_constraint( - schema_editor, - cursor, - follow_table, - "actor_id", - DOCUMENTID_TABLE, - on_delete_cascade=True, - ) - replace_fk_constraint( - schema_editor, - cursor, - follow_table, - "target_id", - DOCUMENTID_TABLE, - on_delete_cascade=True, - ) + if not swapper.is_swapped("baseapp_follows", "Follow"): + _replace_follow_fks(apps, schema_editor, on_delete_cascade=True) def remove_db_level_cascade(apps, schema_editor): @@ -59,25 +133,8 @@ def remove_db_level_cascade(apps, schema_editor): on_delete_cascade=False, ) - if not swapper.is_swapped("baseapp_follows", "Follow"): - Follow = apps.get_model("baseapp_follows", "Follow") - follow_table = Follow._meta.db_table - replace_fk_constraint( - schema_editor, - cursor, - follow_table, - "actor_id", - DOCUMENTID_TABLE, - on_delete_cascade=False, - ) - replace_fk_constraint( - schema_editor, - cursor, - follow_table, - "target_id", - DOCUMENTID_TABLE, - on_delete_cascade=False, - ) + if not swapper.is_swapped("baseapp_follows", "Follow"): + _replace_follow_fks(apps, schema_editor, on_delete_cascade=False) class Migration(migrations.Migration): diff --git a/testproject/follows/migrations/0003_add_db_cascade_to_documentid_fks.py b/testproject/follows/migrations/0003_add_db_cascade_to_documentid_fks.py index 3c6304c1..73a3af1f 100644 --- a/testproject/follows/migrations/0003_add_db_cascade_to_documentid_fks.py +++ b/testproject/follows/migrations/0003_add_db_cascade_to_documentid_fks.py @@ -1,72 +1,24 @@ -from django.db import migrations - -from baseapp_core.db_utils import DOCUMENTID_TABLE, replace_fk_constraint - - -def add_db_level_cascade(apps, schema_editor): - """ - Add ON DELETE CASCADE at the database level for Follow FKs to DocumentId. +import importlib - Django's on_delete=CASCADE only works at the ORM level. The DocumentIdMixin - uses pgtriggers that delete DocumentId rows via raw SQL, bypassing Django's - cascade. This migration makes the DB constraints match the intended behavior. - """ - Follow = apps.get_model("follows", "Follow") - follow_table = Follow._meta.db_table - - with schema_editor.connection.cursor() as cursor: - replace_fk_constraint( - schema_editor, - cursor, - follow_table, - "actor_id", - DOCUMENTID_TABLE, - on_delete_cascade=True, - ) - replace_fk_constraint( - schema_editor, - cursor, - follow_table, - "target_id", - DOCUMENTID_TABLE, - on_delete_cascade=True, - ) - - -def remove_db_level_cascade(apps, schema_editor): - """Reverse: restore FK constraints without ON DELETE CASCADE.""" - Follow = apps.get_model("follows", "Follow") - follow_table = Follow._meta.db_table +from django.db import migrations - with schema_editor.connection.cursor() as cursor: - replace_fk_constraint( - schema_editor, - cursor, - follow_table, - "actor_id", - DOCUMENTID_TABLE, - on_delete_cascade=False, - ) - replace_fk_constraint( - schema_editor, - cursor, - follow_table, - "target_id", - DOCUMENTID_TABLE, - on_delete_cascade=False, - ) +_migration_0009 = importlib.import_module( + "baseapp_follows.migrations.0009_add_db_cascade_to_documentid_fks" +) +_replace_follow_fks = _migration_0009._replace_follow_fks class Migration(migrations.Migration): dependencies = [ ("baseapp_core", "0001_initial"), + ("baseapp_follows", "0009_add_db_cascade_to_documentid_fks"), ("follows", "0002_alter_follow_actor_alter_follow_target"), ] operations = [ migrations.RunPython( - add_db_level_cascade, - remove_db_level_cascade, + lambda apps, se: _replace_follow_fks(apps, se, on_delete_cascade=True), + lambda apps, se: _replace_follow_fks(apps, se, on_delete_cascade=False), ), ]