-
Notifications
You must be signed in to change notification settings - Fork 2
hotfix: user with follows deletion cascade #406
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, | ||
| ), | ||
| ] | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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() |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
|
Comment on lines
+5
to
+8
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 Result: No, Django cannot serialize lambda callables passed to migrations.RunPython because Django's migration system uses Python's pickle module to serialize migration operations for storage and replay, and pickle cannot serialize lambda functions as they are anonymous and lack a proper module-level definition. Multiple sources confirm this limitation, seen in errors like "ValueError: Cannot serialize function: lambda" during makemigrations or migration execution. The recommended way to share helper code between migrations without importing specific numbered migration modules is to define the helper functions in a separate, stable module (e.g., myapp.migrations.helpers or myapp.utils) at the top level of your app package. Import these helpers into each migration file where needed. Since migrations are loaded as Python modules during execution, and helpers are regular top-level functions (not lambdas), they can be imported and used directly in RunPython callables. This approach avoids tight coupling to specific migration numbers, as the helpers module is independent of migration history. Example structure: myapp/ migrations/ init.py helpers.py # def my_helper(apps, schema_editor): ... 0001_initial.py # from .helpers import my_helper; operations=[RunPython(my_helper)] 0002_data.py # Same import Official Django docs emphasize serializing top-level functions and advise writing RunPython code as separate functions above the Migration class. Community practices (Stack Overflow, blogs) consistently recommend app-level helper modules for reusability across data migrations. Citations:
🏁 Script executed: # First, find the files mentioned in the review
find . -type f -name "0003_add_db_cascade_to_documentid_fks.py" | head -5Repository: silverlogic/baseapp-backend Length of output: 144 🏁 Script executed: # Check if baseapp_follows.migrations.0009_* exists
find . -type f -path "*/baseapp_follows/migrations/0009*" | head -5Repository: silverlogic/baseapp-backend Length of output: 140 🏁 Script executed: # Look for helpers modules in baseapp_follows
find . -type f -path "*/baseapp_follows/migrations/*" -name "helper*" | head -10Repository: silverlogic/baseapp-backend Length of output: 53 🏁 Script executed: # Read the migration file to verify the actual code
if [ -f "testproject/follows/migrations/0003_add_db_cascade_to_documentid_fks.py" ]; then
cat -n "testproject/follows/migrations/0003_add_db_cascade_to_documentid_fks.py"
fiRepository: silverlogic/baseapp-backend Length of output: 949 🏁 Script executed: # Check the baseapp_follows 0009 migration to see _replace_follow_fks
cat -n "./baseapp_follows/migrations/0009_add_db_cascade_to_documentid_fks.py"Repository: silverlogic/baseapp-backend Length of output: 6124 🏁 Script executed: # Check if baseapp_follows has any helpers or shared modules
find ./baseapp_follows -type f -name "*.py" | grep -E "(helper|util|shared)" | head -10Repository: silverlogic/baseapp-backend Length of output: 53 🏁 Script executed: # List all files in baseapp_follows/migrations to see structure
ls -la ./baseapp_follows/migrations/ | head -20Repository: silverlogic/baseapp-backend Length of output: 1023 🏁 Script executed: # Check if there are any other migrations importing from numbered migration modules
rg "importlib.import_module.*migrations\." --type py | head -20Repository: silverlogic/baseapp-backend Length of output: 53 🏁 Script executed: # Check if there are other migrations that use lambdas with RunPython
rg "migrations.RunPython\(\s*lambda" --type py | head -20Repository: silverlogic/baseapp-backend Length of output: 53 Replace lambdas with named functions and use a stable module path for shared helpers. Importing ♻️ Suggested refactor _migration_0009 = importlib.import_module(
"baseapp_follows.migrations.0009_add_db_cascade_to_documentid_fks"
)
_replace_follow_fks = _migration_0009._replace_follow_fks
+
+def add_db_level_cascade(apps, schema_editor):
+ _replace_follow_fks(apps, schema_editor, on_delete_cascade=True)
+
+
+def remove_db_level_cascade(apps, schema_editor):
+ _replace_follow_fks(apps, schema_editor, on_delete_cascade=False)
+
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),
+ add_db_level_cascade,
+ remove_db_level_cascade,
),
]🤖 Prompt for AI Agents
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @anicioalexandre can you implement this? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
|
|
||
| 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), | ||
| ), | ||
| ] | ||
Uh oh!
There was an error while loading. Please reload this page.