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..74fe4712 --- /dev/null +++ b/baseapp_follows/migrations/0009_add_db_cascade_to_documentid_fks.py @@ -0,0 +1,153 @@ +import swapper +from django.db import migrations + +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): + """ + 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 constraints match the intended behavior. + """ + FollowStats = apps.get_model("baseapp_follows", "FollowStats") + + with schema_editor.connection.cursor() as cursor: + 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"): + _replace_follow_fks(apps, schema_editor, on_delete_cascade=True) + + +def remove_db_level_cascade(apps, schema_editor): + """Reverse: restore FK constraints without ON DELETE CASCADE.""" + FollowStats = apps.get_model("baseapp_follows", "FollowStats") + + with schema_editor.connection.cursor() as cursor: + 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"): + _replace_follow_fks(apps, schema_editor, 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 = [ + 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..73a3af1f --- /dev/null +++ b/testproject/follows/migrations/0003_add_db_cascade_to_documentid_fks.py @@ -0,0 +1,24 @@ +import importlib + +from django.db import migrations + +_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( + lambda apps, se: _replace_follow_fks(apps, se, on_delete_cascade=True), + lambda apps, se: _replace_follow_fks(apps, se, on_delete_cascade=False), + ), + ]