Skip to content

Commit 32508b8

Browse files
authored
Merge pull request #760 from bckohan/issue759
Fix migration state check false negative
2 parents 9f5e0fc + ffd2d6b commit 32508b8

File tree

6 files changed

+96
-4
lines changed

6 files changed

+96
-4
lines changed

docs/changelog.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,18 @@
11
Changelog
22
=========
33

4+
v4.5.1 (2025-12-24)
5+
-------------------
6+
7+
* Fixed `4.5.0 generates a lot of migrations on my project <https://github.com/jazzband/django-polymorphic/pull/759>`_
8+
49
v4.5.0 (2025-12-22)
510
-------------------
611

12+
.. warning::
13+
14+
This version has a bug that generates unnecessary migrations - use 4.5.1 instead!
15+
716
* Implemented `Deletion fixes <https://github.com/jazzband/django-polymorphic/pull/746>`_
817
**This release fixes the longstanding polymorphic deletion bug.** The fix should be transparent
918
and not generate new migrations files. If you experience any issues, please report them.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "django-polymorphic"
7-
version = "4.5.0"
7+
version = "4.5.1"
88
description = "Seamless polymorphic inheritance for Django models."
99
readme = "README.md"
1010
license = "BSD-3-Clause"

src/polymorphic/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
Seamless Polymorphic Inheritance for Django Models
2020
"""
2121

22-
VERSION = "4.5.0"
22+
VERSION = "4.5.1"
2323

2424
__title__ = "Django Polymorphic"
2525
__version__ = VERSION # version synonym for backwards compatibility

src/polymorphic/deletion.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,35 @@
22
Classes and utilities for handling deletions in polymorphic models.
33
"""
44

5+
from functools import cached_property
6+
57
from django.db.migrations.serializer import BaseSerializer, serializer_factory
68
from django.db.migrations.writer import MigrationWriter
79

810
from .query import PolymorphicQuerySet
911

1012

13+
def migration_fingerprint(value):
14+
"""
15+
Produce a stable, hashable fingerprint for a value as Django would represent
16+
it in migrations, but in a structured form when possible.
17+
"""
18+
# Canonical deconstruction path for SET(...), @deconstructible, etc.
19+
deconstruct = getattr(value, "deconstruct", None)
20+
if callable(deconstruct):
21+
path, args, kwargs = value.deconstruct()
22+
return (
23+
path,
24+
tuple(migration_fingerprint(a) for a in args),
25+
tuple(sorted((k, migration_fingerprint(v)) for k, v in kwargs.items())),
26+
)
27+
28+
# Fallback: canonical "code string" Django would emit in a migration.
29+
# (Works for CASCADE/PROTECT/SET_NULL, primitives, etc.)
30+
code, _imports = serializer_factory(value).serialize()
31+
return code
32+
33+
1134
class PolymorphicGuard:
1235
"""
1336
Wrap an :attr:`django.db.models.ForeignKey.on_delete` callable
@@ -43,6 +66,36 @@ class MyModel(PolymorphicModel):
4366
sub_objs = sub_objs.non_polymorphic()
4467
return self.action(collector, field, sub_objs, using)
4568

69+
@cached_property
70+
def migration_key(self):
71+
return migration_fingerprint(self.action)
72+
73+
def __eq__(self, other):
74+
if (
75+
isinstance(other, tuple)
76+
and len(other) == 3
77+
and callable(getattr(self.action, "deconstruct", None))
78+
):
79+
# In some cases the autodetector compares us to a reconstructed,
80+
# deconstruct() tuple. This has been seen for SET(...) callables.
81+
# The arguments element may be a list instead of a tuple though, this
82+
# handles that special case
83+
return self.action.deconstruct() == (
84+
other[0],
85+
tuple(other[1]) if isinstance(other[1], list) else other[1],
86+
other[2],
87+
)
88+
if isinstance(other, PolymorphicGuard):
89+
return self.migration_key == other.migration_key
90+
else:
91+
try:
92+
return self.migration_key == migration_fingerprint(other)
93+
except Exception:
94+
return False
95+
96+
def __hash__(self):
97+
return hash(self.migration_key)
98+
4699

47100
class PolymorphicGuardSerializer(BaseSerializer):
48101
"""

src/polymorphic/tests/test_migrations/test_on_delete.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
with PolymorphicGuard and serialize correctly in migrations.
77
"""
88

9-
import shutil
9+
from django.core.management import call_command
1010
from pathlib import Path
1111
from django.test import TestCase, TransactionTestCase
1212
from django.db import models
@@ -582,3 +582,33 @@ def test_one_to_one_set_null_sets_to_null(self):
582582
# Verify the object still exists but the field is now null
583583
one_to_one_obj = ModelWithOneToOneSetNull.objects.get(id=one_to_one_obj_id)
584584
self.assertIsNone(one_to_one_obj.related)
585+
586+
587+
class TestMigrationStateStability(TestCase):
588+
"""
589+
Test that unchanged models do not generate new migrations.
590+
"""
591+
592+
def test_migration_state_stability(self):
593+
call_command("makemigrations")
594+
595+
migrations_dirs = [
596+
Path(__file__).parent.parent / "deletion" / "migrations",
597+
Path(__file__).parent.parent / "test_migrations" / "migrations",
598+
Path(__file__).parent.parent / "migrations",
599+
]
600+
601+
migrations = set()
602+
603+
for migrations_dir in migrations_dirs:
604+
migrations.update(migrations_dir.glob("00*.py"))
605+
606+
call_command("makemigrations")
607+
call_command("makemigrations")
608+
609+
migrations_post = set()
610+
611+
for migrations_dir in migrations_dirs:
612+
migrations_post.update(migrations_dir.glob("00*.py"))
613+
614+
self.assertEqual(migrations, migrations_post)

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)