Skip to content

Commit 246a128

Browse files
committed
Add polymorphic guard to solve deletion problems.
Includes docs and robust tests.
1 parent c3136da commit 246a128

File tree

25 files changed

+3273
-201
lines changed

25 files changed

+3273
-201
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ local_settings.py
6161
db.sqlite3
6262
db.sqlite3-journal
6363

64+
# Test migrations (generated dynamically by tests)
65+
src/polymorphic/tests/test_migrations/migrations/0*.py
66+
6467
# Flask stuff:
6568
instance/
6669
.webassets-cache

docs/api/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ API Documentation
1616
polymorphic.formsets
1717
polymorphic.managers
1818
polymorphic.models
19+
polymorphic.deletion
1920
polymorphic.query
2021
polymorphic.showfields
2122
polymorphic.templatetags

docs/api/polymorphic.deletion.rst

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
polymorphic.models
2+
==================
3+
4+
.. automodule:: polymorphic.deletion
5+
6+
.. autoclass:: polymorphic.deletion.PolymorphicGuard
7+
:members: __call__
8+
:show-inheritance:
9+
10+
.. autoclass:: polymorphic.deletion.PolymorphicGuardSerializer
11+
:members:
12+
:show-inheritance:

docs/deletion.rst

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
Deletion
2+
========
3+
4+
.. versionadded:: 4.5.0
5+
6+
There is nothing special about deleting polymorphic models. The same rules apply as to
7+
:ref:`the deletion of normal Django models <topics-db-queries-delete>` that have parent/child
8+
relationships up and down a model inheritance hierarchy. Django must walk the model inheritance and
9+
relationship graph and collect all of the affected objects so that it can correctly order deletion
10+
SQL statements to respect database constraints and issue signals.
11+
12+
The polymorphic deletion logic is the same as the normal Django deletion logic because Django
13+
already walks the model inheritance hierarchy. :class:`~polymorphic.query.PolymorphicQuerySet` and
14+
:class:`~polymorphic.managers.PolymorphicManager` disrupt this process by confusing Django's graph
15+
walker by returning concrete subclass instances instead of base class instances when it attempts to
16+
walk reverse relationships to polymorphic models. To prevent this confusion,
17+
:pypi:`django-polymorphic` wraps the :attr:`~django.db.models.ForeignKey.on_delete` handlers of
18+
reverse relations to polymorphic models with :class:`~polymorphic.deletion.PolymorphicGuard`
19+
which disables polymorphic behavior on the related querysets during collection.
20+
21+
**You may define your polymorphic models as you normally would using the standard Django**
22+
:attr:`~django.db.models.ForeignKey.on_delete` **actions**.
23+
:class:`~polymorphic.models.PolymorphicModel` will automatically wrap the actions for you. actions
24+
wrapped with :class:`~polymorphic.deletion.PolymorphicGuard` serialize in migrations as the
25+
underlying wrapped action. This ensures migrations generated by versions of
26+
:pypi:`django-polymorphic` after 4.5.0 should be the same as with prior versions. The guard is also
27+
unnecessary during migrations because Django generates basic managers instead of using the default
28+
polymorphic managers.
29+
30+
It is a design goal of :pypi:`django-polymorphic` that deletion should just work without any special
31+
treatment. However if you encounter attribute errors or database integrity errors during deletion
32+
you may manually wrap the :attr:`~django.db.models.ForeignKey.on_delete` action of reverse relations
33+
to polymorphic models with :class:`~polymorphic.deletion.PolymorphicGuard` to disable polymorphic
34+
behavior during deletion collection. If you encounter an issue like this
35+
`please report it to us <https://github.com/jazzband/django-polymorphic/issues>`_. For example:
36+
37+
.. code-block:: python
38+
39+
from polymorphic.models import PolymorphicModel
40+
from polymorphic.deletion import PolymorphicGuard
41+
from django.db import models
42+
43+
class MyModel(models.Model):
44+
# ...
45+
46+
class RelatedModel(PolymorphicModel):
47+
my_model = models.ForeignKey(
48+
MyModel,
49+
on_delete=PolymorphicGuard(models.CASCADE),
50+
)
51+

docs/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ Advanced topics
119119
formsets
120120
migrating
121121
managers
122+
deletion
122123
advanced
123124
changelog
124125
api/index

justfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ build: build-docs-html
9797
# regenerate test migrations using the lowest version of Django
9898
make-test-migrations:
9999
- rm src/polymorphic/tests/migrations/00*.py
100+
- rm src/polymorphic/tests/deletion/migrations/00*.py
100101
uv run --isolated --resolution lowest-direct --script ./manage.py makemigrations
101102

102103
# open the html documentation

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ show_missing = true
143143
dev = [
144144
"coverage>=7.6.1",
145145
"dj-database-url>=2.2.0",
146+
"django-test-migrations>=1.5.0",
146147
"ipdb>=0.13.13",
147148
"ipython>=8.18.1",
148149
"mypy>=1.14.1",

src/polymorphic/base.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from django.db import models
1111
from django.db.models.base import ModelBase
1212

13+
from .deletion import PolymorphicGuard
1314
from .managers import PolymorphicManager
1415
from .query import PolymorphicQuerySet
1516

@@ -75,6 +76,14 @@ def __new__(cls, model_name, bases, attrs, **kwargs):
7576
if new_class._meta.pk:
7677
new_class.polymorphic_primary_key_name = new_class._meta.pk.name
7778

79+
# wrap on_delete handlers of reverse relations back to this model with the
80+
# polymorphic deletion guard
81+
for fk in new_class._meta.fields:
82+
if isinstance(fk, (models.ForeignKey, models.OneToOneField)) and not isinstance(
83+
fk.remote_field.on_delete, PolymorphicGuard
84+
):
85+
fk.remote_field.on_delete = PolymorphicGuard(fk.remote_field.on_delete)
86+
7887
return new_class
7988

8089
@classmethod

src/polymorphic/deletion.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""
2+
Classes and utilities for handling deletions in polymorphic models.
3+
"""
4+
5+
from django.db.migrations.serializer import BaseSerializer, serializer_factory
6+
from django.db.migrations.writer import MigrationWriter
7+
8+
from .query import PolymorphicQuerySet
9+
10+
11+
class PolymorphicGuard:
12+
"""
13+
Wrap an :attr:`django.db.models.ForeignKey.on_delete` callable
14+
(CASCADE/PROTECT/SET_NULL/SET(...)/custom), but serialize as the underlying
15+
callable.
16+
17+
:param action: The :attr:`django.db.models.ForeignKey.on_delete` callable to wrap.
18+
"""
19+
20+
def __init__(self, action):
21+
if not callable(action):
22+
raise TypeError("action must be callable")
23+
self.action = action
24+
25+
def __call__(self, collector, field, sub_objs, using):
26+
"""
27+
This guard wraps an on_delete action to ensure that any polymorphic queryset
28+
passed to it is converted to a non-polymorphic queryset before proceeding.
29+
This prevents issues with cascading deletes on polymorphic models.
30+
31+
This guard should be automatically applied to reverse relations such that
32+
33+
.. code-block:: python
34+
35+
class MyModel(PolymorphicModel):
36+
related = models.ForeignKey(
37+
OtherModel,
38+
on_delete=models.CASCADE # <- equal to PolymorphicGuard(models.CASCADE)
39+
)
40+
41+
"""
42+
if isinstance(sub_objs, PolymorphicQuerySet) and not sub_objs.polymorphic_disabled:
43+
sub_objs = sub_objs.non_polymorphic()
44+
return self.action(collector, field, sub_objs, using)
45+
46+
47+
class PolymorphicGuardSerializer(BaseSerializer):
48+
"""
49+
A serializer for PolymorphicGuard that serializes the underlying action.
50+
51+
There is no need to serialize the PolymorphicGuard itself, as it is just a wrapper
52+
that ensures that polymorphic querysets are converted to non-polymorphic but no
53+
polymorphic managers are present in migrations. This also ensures that new
54+
migrations will not be generated.
55+
"""
56+
57+
def serialize(self):
58+
"""
59+
Serialize the underlying action of the PolymorphicGuard.
60+
"""
61+
return serializer_factory(self.value.action).serialize()
62+
63+
64+
MigrationWriter.register_serializer(PolymorphicGuard, PolymorphicGuardSerializer)

src/polymorphic/query.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -546,3 +546,12 @@ def get_real_instances(self, base_result_objects=None):
546546
return olist
547547
clist = PolymorphicQuerySet._p_list_class(olist)
548548
return clist
549+
550+
def delete(self):
551+
"""
552+
Deletion will be done non-polymorphically because Django's multi-table deletion
553+
mechanism is already walking the class hierarchy and producing a correct
554+
deletion graph. Introducing polymorphic querysets into the deletion process
555+
disrupts the model hierarchy/relationship traversal.
556+
"""
557+
return QuerySet.delete(self.non_polymorphic())

0 commit comments

Comments
 (0)