Skip to content
Open
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
153 changes: 153 additions & 0 deletions baseapp_follows/migrations/0009_add_db_cascade_to_documentid_fks.py
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,
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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,
),
]
50 changes: 50 additions & 0 deletions baseapp_follows/tests/test_user_deletion_cascade.py
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
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

Can Django serialize lambda callables passed to migrations.RunPython, and what is the recommended way to share helper code between migrations without importing a specific numbered migration module?

💡 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 -5

Repository: 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 -5

Repository: 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 -10

Repository: 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"
fi

Repository: 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 -10

Repository: 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 -20

Repository: 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 -20

Repository: 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 -20

Repository: silverlogic/baseapp-backend

Length of output: 53


Replace lambdas with named functions and use a stable module path for shared helpers.

Importing _replace_follow_fks from baseapp_follows.migrations.0009_add_db_cascade_to_documentid_fks hard-couples this migration to that specific numbered module. More critically, the RunPython lambdas (lines 20–22) cannot be serialized by Django's migration system—this will fail during squashmigrations or when Django tries to record the migration state. Define top-level functions here instead, following the pattern already used in baseapp_follows 0009.

♻️ 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
Verify each finding against the current code and only fix it if needed.

In `@testproject/follows/migrations/0003_add_db_cascade_to_documentid_fks.py`
around lines 5 - 8, Replace the inline RunPython lambdas with top-level named
functions in this migration (e.g., add functions like
forwards_replace_follow_fks(apps, schema_editor) and
backwards_replace_follow_fks(apps, schema_editor)) and call those in the
RunPython operations instead of lambdas (they currently appear around the
RunPython calls). Also stop importing the helper from a versioned module name;
either import the shared helper from a stable module path (e.g., a dedicated
helpers module such as baseapp_follows.migrations.helpers._replace_follow_fks)
or copy the necessary logic into a local top-level function named
_replace_follow_fks so the migration is self-contained and serializable by
Django.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@anicioalexandre can you implement this?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!



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),
),
]
Loading