diff options
| author | Mariusz Felisiak <felisiak.mariusz@gmail.com> | 2025-10-18 15:03:50 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-10-18 15:03:50 +0200 |
| commit | 0c487aa3a7b2417481bf48c1e5355c855873e210 (patch) | |
| tree | 33b92a3bdf11f66d0f67fe4b4338084c7028c709 /tests | |
| parent | b1e0262c9f9d11eae6230b51c5aa5d71122d5f05 (diff) | |
Fixed #21961 -- Added support for database-level delete options for ForeignKey.
Thanks Simon Charette for pair programming.
Co-authored-by: Nick Stefan <NickStefan12@gmail.com>
Co-authored-by: Akash Kumar Sen <71623442+Akash-Kumar-Sen@users.noreply.github.com>
Co-authored-by: Simon Charette <charette.s@gmail.com>
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/admin_utils/models.py | 10 | ||||
| -rw-r--r-- | tests/admin_utils/tests.py | 25 | ||||
| -rw-r--r-- | tests/contenttypes_tests/test_checks.py | 21 | ||||
| -rw-r--r-- | tests/delete/models.py | 49 | ||||
| -rw-r--r-- | tests/delete/tests.py | 51 | ||||
| -rw-r--r-- | tests/invalid_models_tests/test_models.py | 61 | ||||
| -rw-r--r-- | tests/invalid_models_tests/test_relative_fields.py | 198 | ||||
| -rw-r--r-- | tests/migrations/test_writer.py | 17 | ||||
| -rw-r--r-- | tests/schema/tests.py | 96 |
9 files changed, 523 insertions, 5 deletions
diff --git a/tests/admin_utils/models.py b/tests/admin_utils/models.py index 243f314b03..e5d2b67887 100644 --- a/tests/admin_utils/models.py +++ b/tests/admin_utils/models.py @@ -40,7 +40,7 @@ class ArticleProxy(Article): proxy = True -class Count(models.Model): +class Cascade(models.Model): num = models.PositiveSmallIntegerField() parent = models.ForeignKey("self", models.CASCADE, null=True) @@ -48,6 +48,14 @@ class Count(models.Model): return str(self.num) +class DBCascade(models.Model): + num = models.PositiveSmallIntegerField() + parent = models.ForeignKey("self", models.DB_CASCADE, null=True) + + def __str__(self): + return str(self.num) + + class Event(models.Model): date = models.DateTimeField(auto_now_add=True) diff --git a/tests/admin_utils/tests.py b/tests/admin_utils/tests.py index c90836c6d8..ce32535c52 100644 --- a/tests/admin_utils/tests.py +++ b/tests/admin_utils/tests.py @@ -26,7 +26,17 @@ from django.test.utils import isolate_apps from django.utils.formats import localize from django.utils.safestring import mark_safe -from .models import Article, Car, Count, Event, EventGuide, Location, Site, Vehicle +from .models import ( + Article, + Car, + Cascade, + DBCascade, + Event, + EventGuide, + Location, + Site, + Vehicle, +) class NestedObjectsTests(TestCase): @@ -34,10 +44,12 @@ class NestedObjectsTests(TestCase): Tests for ``NestedObject`` utility collection. """ + cascade_model = Cascade + @classmethod def setUpTestData(cls): cls.n = NestedObjects(using=DEFAULT_DB_ALIAS) - cls.objs = [Count.objects.create(num=i) for i in range(5)] + cls.objs = [cls.cascade_model.objects.create(num=i) for i in range(5)] def _check(self, target): self.assertEqual(self.n.nested(lambda obj: obj.num), target) @@ -103,6 +115,15 @@ class NestedObjectsTests(TestCase): n.collect([Vehicle.objects.first()]) +class DBNestedObjectsTests(NestedObjectsTests): + """ + Exercise NestedObjectsTests but with a model that makes use of DB_CASCADE + instead of CASCADE to ensure proper collection of objects takes place. + """ + + cascade_model = DBCascade + + class UtilsTests(SimpleTestCase): empty_value = "-empty-" diff --git a/tests/contenttypes_tests/test_checks.py b/tests/contenttypes_tests/test_checks.py index c33920f6b7..5c88b71777 100644 --- a/tests/contenttypes_tests/test_checks.py +++ b/tests/contenttypes_tests/test_checks.py @@ -80,6 +80,27 @@ class GenericForeignKeyTests(SimpleTestCase): ], ) + def test_content_type_db_on_delete(self): + class Model(models.Model): + content_type = models.ForeignKey(ContentType, models.DB_CASCADE) + object_id = models.PositiveIntegerField() + content_object = GenericForeignKey("content_type", "object_id") + + field = Model._meta.get_field("content_object") + + self.assertEqual( + field.check(), + [ + checks.Error( + "'Model.content_type' cannot use the database-level on_delete " + "variant.", + hint="Change the on_delete rule to the non-database variant.", + obj=field, + id="contenttypes.E006", + ) + ], + ) + def test_missing_object_id_field(self): class TaggedItem(models.Model): content_type = models.ForeignKey(ContentType, models.CASCADE) diff --git a/tests/delete/models.py b/tests/delete/models.py index 7f123b3396..bd9caf42a7 100644 --- a/tests/delete/models.py +++ b/tests/delete/models.py @@ -41,6 +41,46 @@ class RChildChild(RChild): pass +class RelatedDbOptionGrandParent(models.Model): + pass + + +class RelatedDbOptionParent(models.Model): + p = models.ForeignKey(RelatedDbOptionGrandParent, models.DB_CASCADE, null=True) + + +class RelatedDbOption(models.Model): + name = models.CharField(max_length=30) + db_setnull = models.ForeignKey( + RelatedDbOptionParent, + models.DB_SET_NULL, + null=True, + related_name="db_setnull_set", + ) + db_cascade = models.ForeignKey( + RelatedDbOptionParent, models.DB_CASCADE, related_name="db_cascade_set" + ) + + +class SetDefaultDbModel(models.Model): + db_setdefault = models.ForeignKey( + RelatedDbOptionParent, + models.DB_SET_DEFAULT, + db_default=models.Value(1), + related_name="db_setdefault_set", + ) + db_setdefault_none = models.ForeignKey( + RelatedDbOptionParent, + models.DB_SET_DEFAULT, + db_default=None, + null=True, + related_name="db_setnull_nullable_set", + ) + + class Meta: + required_db_features = {"supports_on_delete_db_default"} + + class A(models.Model): name = models.CharField(max_length=30) @@ -119,6 +159,15 @@ def create_a(name): return a +def create_related_db_option(name): + a = RelatedDbOption(name=name) + for name in ["db_setnull", "db_cascade"]: + r = RelatedDbOptionParent.objects.create() + setattr(a, name, r) + a.save() + return a + + class M(models.Model): m2m = models.ManyToManyField(R, related_name="m_set") m2m_through = models.ManyToManyField(R, through="MR", related_name="m_through_set") diff --git a/tests/delete/tests.py b/tests/delete/tests.py index 59140b5c62..8d525d1e5f 100644 --- a/tests/delete/tests.py +++ b/tests/delete/tests.py @@ -34,11 +34,16 @@ from .models import ( RChild, RChildChild, Referrer, + RelatedDbOption, + RelatedDbOptionGrandParent, + RelatedDbOptionParent, RProxy, S, + SetDefaultDbModel, T, User, create_a, + create_related_db_option, get_default_r, ) @@ -76,18 +81,48 @@ class OnDeleteTests(TestCase): a = A.objects.get(pk=a.pk) self.assertIsNone(a.setnull) + def test_db_setnull(self): + a = create_related_db_option("db_setnull") + a.db_setnull.delete() + a = RelatedDbOption.objects.get(pk=a.pk) + self.assertIsNone(a.db_setnull) + def test_setdefault(self): a = create_a("setdefault") a.setdefault.delete() a = A.objects.get(pk=a.pk) self.assertEqual(self.DEFAULT, a.setdefault.pk) + @skipUnlessDBFeature("supports_on_delete_db_default") + def test_db_setdefault(self): + # Object cannot be created on the module initialization, use hardcoded + # PKs instead. + r = RelatedDbOptionParent.objects.create(pk=2) + default_r = RelatedDbOptionParent.objects.create(pk=1) + set_default_db_obj = SetDefaultDbModel.objects.create(db_setdefault=r) + set_default_db_obj.db_setdefault.delete() + set_default_db_obj = SetDefaultDbModel.objects.get(pk=set_default_db_obj.pk) + self.assertEqual(set_default_db_obj.db_setdefault, default_r) + def test_setdefault_none(self): a = create_a("setdefault_none") a.setdefault_none.delete() a = A.objects.get(pk=a.pk) self.assertIsNone(a.setdefault_none) + @skipUnlessDBFeature("supports_on_delete_db_default") + def test_db_setdefault_none(self): + # Object cannot be created on the module initialization, use hardcoded + # PKs instead. + r = RelatedDbOptionParent.objects.create(pk=2) + default_r = RelatedDbOptionParent.objects.create(pk=1) + set_default_db_obj = SetDefaultDbModel.objects.create( + db_setdefault_none=r, db_setdefault=default_r + ) + set_default_db_obj.db_setdefault_none.delete() + set_default_db_obj = SetDefaultDbModel.objects.get(pk=set_default_db_obj.pk) + self.assertIsNone(set_default_db_obj.db_setdefault_none) + def test_cascade(self): a = create_a("cascade") a.cascade.delete() @@ -359,6 +394,22 @@ class DeletionTests(TestCase): self.assertNumQueries(5, s.delete) self.assertFalse(S.objects.exists()) + def test_db_cascade(self): + related_db_op = RelatedDbOptionParent.objects.create( + p=RelatedDbOptionGrandParent.objects.create() + ) + RelatedDbOption.objects.bulk_create( + [ + RelatedDbOption(db_cascade=related_db_op) + for _ in range(2 * GET_ITERATOR_CHUNK_SIZE) + ] + ) + with self.assertNumQueries(1): + results = related_db_op.delete() + self.assertEqual(results, (1, {"delete.RelatedDbOptionParent": 1})) + self.assertFalse(RelatedDbOption.objects.exists()) + self.assertFalse(RelatedDbOptionParent.objects.exists()) + def test_instance_update(self): deleted = [] related_setnull_sets = [] diff --git a/tests/invalid_models_tests/test_models.py b/tests/invalid_models_tests/test_models.py index 2a39e250bd..fe3c812615 100644 --- a/tests/invalid_models_tests/test_models.py +++ b/tests/invalid_models_tests/test_models.py @@ -3062,3 +3062,64 @@ class ConstraintsTests(TestCase): ), ], ) + + +@isolate_apps("invalid_models_tests") +class RelatedFieldTests(SimpleTestCase): + def test_on_delete_python_db_variants(self): + class Artist(models.Model): + pass + + class Album(models.Model): + artist = models.ForeignKey(Artist, models.CASCADE) + + class Song(models.Model): + album = models.ForeignKey(Album, models.RESTRICT) + artist = models.ForeignKey(Artist, models.DB_CASCADE) + + self.assertEqual( + Song.check(databases=self.databases), + [ + Error( + "The model cannot have related fields with both database-level and " + "Python-level on_delete variants.", + obj=Song, + id="models.E050", + ), + ], + ) + + def test_on_delete_python_db_variants_auto_created(self): + class SharedModel(models.Model): + pass + + class Parent(models.Model): + pass + + class Child(SharedModel): + parent = models.ForeignKey(Parent, on_delete=models.DB_CASCADE) + + self.assertEqual( + Child.check(databases=self.databases), + [ + Error( + "The model cannot have related fields with both database-level and " + "Python-level on_delete variants.", + obj=Child, + id="models.E050", + ), + ], + ) + + def test_on_delete_db_do_nothing(self): + class Artist(models.Model): + pass + + class Album(models.Model): + artist = models.ForeignKey(Artist, models.CASCADE) + + class Song(models.Model): + album = models.ForeignKey(Album, models.DO_NOTHING) + artist = models.ForeignKey(Artist, models.DB_CASCADE) + + self.assertEqual(Song.check(databases=self.databases), []) diff --git a/tests/invalid_models_tests/test_relative_fields.py b/tests/invalid_models_tests/test_relative_fields.py index ed6d39f7c6..e73f22ab41 100644 --- a/tests/invalid_models_tests/test_relative_fields.py +++ b/tests/invalid_models_tests/test_relative_fields.py @@ -3,7 +3,8 @@ from unittest import mock from django.core.checks import Error from django.core.checks import Warning as DjangoWarning from django.db import connection, models -from django.test.testcases import SimpleTestCase +from django.test import skipUnlessDBFeature +from django.test.testcases import SimpleTestCase, TestCase from django.test.utils import isolate_apps, modify_settings, override_settings @@ -751,6 +752,29 @@ class RelativeFieldTests(SimpleTestCase): ], ) + def test_on_delete_db_set_null_on_non_nullable_field(self): + class Person(models.Model): + pass + + class Model(models.Model): + foreign_key = models.ForeignKey("Person", models.DB_SET_NULL) + + field = Model._meta.get_field("foreign_key") + self.assertEqual( + field.check(), + [ + Error( + "Field specifies on_delete=DB_SET_NULL, but cannot be null.", + hint=( + "Set null=True argument on the field, or change the on_delete " + "rule." + ), + obj=field, + id="fields.E320", + ), + ], + ) + def test_on_delete_set_default_without_default_value(self): class Person(models.Model): pass @@ -2259,3 +2283,175 @@ class M2mThroughFieldsTests(SimpleTestCase): ), ], ) + + +@isolate_apps("invalid_models_tests") +class DatabaseLevelOnDeleteTests(TestCase): + + def test_db_set_default_support(self): + class Parent(models.Model): + pass + + class Child(models.Model): + parent = models.ForeignKey( + Parent, models.DB_SET_DEFAULT, db_default=models.Value(1) + ) + + field = Child._meta.get_field("parent") + expected = ( + [] + if connection.features.supports_on_delete_db_default + else [ + Error( + f"{connection.display_name} does not support a DB_SET_DEFAULT.", + hint="Change the on_delete rule to SET_DEFAULT.", + obj=field, + id="fields.E324", + ) + ] + ) + self.assertEqual(field.check(databases=self.databases), expected) + + def test_db_set_default_required_db_features(self): + class Parent(models.Model): + pass + + class Child(models.Model): + parent = models.ForeignKey( + Parent, models.DB_SET_DEFAULT, db_default=models.Value(1) + ) + + class Meta: + required_db_features = {"supports_on_delete_db_default"} + + field = Child._meta.get_field("parent") + self.assertEqual(field.check(databases=self.databases), []) + + @skipUnlessDBFeature("supports_on_delete_db_default") + def test_db_set_default_no_db_default(self): + class Parent(models.Model): + pass + + class Child(models.Model): + parent = models.ForeignKey(Parent, models.DB_SET_DEFAULT) + + field = Child._meta.get_field("parent") + self.assertEqual( + field.check(databases=self.databases), + [ + Error( + "Field specifies on_delete=DB_SET_DEFAULT, but has no db_default " + "value.", + hint="Set a db_default value, or change the on_delete rule.", + obj=field, + id="fields.E322", + ) + ], + ) + + def test_python_db_chain(self): + class GrandParent(models.Model): + pass + + class Parent(models.Model): + grand_parent = models.ForeignKey(GrandParent, models.DB_CASCADE) + + class Child(models.Model): + parent = models.ForeignKey(Parent, models.RESTRICT) + + field = Child._meta.get_field("parent") + self.assertEqual( + field.check(databases=self.databases), + [ + Error( + "Field specifies Python-level on_delete variant, but referenced " + "model uses database-level variant.", + hint=( + "Use either database or Python on_delete variants uniformly in " + "the references chain." + ), + obj=field, + id="fields.E323", + ) + ], + ) + + def test_db_python_chain(self): + class GrandParent(models.Model): + pass + + class Parent(models.Model): + grand_parent = models.ForeignKey(GrandParent, models.CASCADE) + + class Child(models.Model): + parent = models.ForeignKey(Parent, models.DB_SET_NULL, null=True) + + field = Child._meta.get_field("parent") + self.assertEqual( + field.check(databases=self.databases), + [ + Error( + "Field specifies database-level on_delete variant, but referenced " + "model uses Python-level variant.", + hint=( + "Use either database or Python on_delete variants uniformly in " + "the references chain." + ), + obj=field, + id="fields.E323", + ) + ], + ) + + def test_db_python_chain_auto_created(self): + class GrandParent(models.Model): + pass + + class Parent(GrandParent): + pass + + class Child(models.Model): + parent = models.ForeignKey(Parent, on_delete=models.DB_CASCADE) + + field = Child._meta.get_field("parent") + self.assertEqual( + field.check(databases=self.databases), + [ + Error( + "Field specifies database-level on_delete variant, but referenced " + "model uses Python-level variant.", + hint=( + "Use either database or Python on_delete variants uniformly in " + "the references chain." + ), + obj=field, + id="fields.E323", + ) + ], + ) + + def test_db_do_nothing_chain(self): + class GrandParent(models.Model): + pass + + class Parent(models.Model): + grand_parent = models.ForeignKey(GrandParent, models.DO_NOTHING) + + class Child(models.Model): + parent = models.ForeignKey(Parent, models.DB_SET_NULL, null=True) + + field = Child._meta.get_field("parent") + self.assertEqual(field.check(databases=self.databases), []) + + def test_do_nothing_db_chain(self): + class GrandParent(models.Model): + pass + + class Parent(models.Model): + grand_parent = models.ForeignKey(GrandParent, models.DB_SET_NULL, null=True) + + class Child(models.Model): + parent = models.ForeignKey(Parent, models.DO_NOTHING) + + field = Child._meta.get_field("parent") + self.assertEqual(field.check(databases=self.databases), []) diff --git a/tests/migrations/test_writer.py b/tests/migrations/test_writer.py index 29f472b85b..b7b199d8f1 100644 --- a/tests/migrations/test_writer.py +++ b/tests/migrations/test_writer.py @@ -971,6 +971,23 @@ class WriterTests(SimpleTestCase): ("('models.Model', {'from django.db import models'})", set()), ) + def test_database_on_delete_serializer_value(self): + db_level_on_delete_options = [ + models.DB_CASCADE, + models.DB_SET_DEFAULT, + models.DB_SET_NULL, + ] + for option in db_level_on_delete_options: + self.assertSerializedEqual(option) + self.assertSerializedResultEqual( + MigrationWriter.serialize(option), + ( + f"('django.db.models.deletion.{option.__name__}', " + "{'import django.db.models.deletion'})", + set(), + ), + ) + def test_simple_migration(self): """ Tests serializing a simple migration. diff --git a/tests/schema/tests.py b/tests/schema/tests.py index 88d4ebbc8b..ab8b07e9d3 100644 --- a/tests/schema/tests.py +++ b/tests/schema/tests.py @@ -18,6 +18,8 @@ from django.db import ( from django.db.backends.utils import truncate_name from django.db.models import ( CASCADE, + DB_CASCADE, + DB_SET_NULL, PROTECT, AutoField, BigAutoField, @@ -411,6 +413,40 @@ class SchemaTests(TransactionTestCase): ) @skipUnlessDBFeature("can_create_inline_fk") + def test_inline_fk_db_on_delete(self): + with connection.schema_editor() as editor: + editor.create_model(Author) + editor.create_model(Book) + editor.create_model(Note) + self.assertForeignKeyNotExists(Note, "book_id", "schema_book") + # Add a foreign key from model to the other. + with ( + CaptureQueriesContext(connection) as ctx, + connection.schema_editor() as editor, + ): + new_field = ForeignKey(Book, DB_CASCADE) + new_field.set_attributes_from_name("book") + editor.add_field(Note, new_field) + self.assertForeignKeyExists(Note, "book_id", "schema_book") + # Creating a FK field with a constraint uses a single statement without + # a deferred ALTER TABLE. + self.assertFalse( + [ + sql + for sql in (str(statement) for statement in editor.deferred_sql) + if sql.startswith("ALTER TABLE") and "ADD CONSTRAINT" in sql + ] + ) + # ON DELETE clause is used. + self.assertTrue( + any( + capture_query["sql"].startswith("ALTER TABLE") + and "ON DELETE" in capture_query["sql"] + for capture_query in ctx.captured_queries + ) + ) + + @skipUnlessDBFeature("can_create_inline_fk") def test_add_inline_fk_update_data(self): with connection.schema_editor() as editor: editor.create_model(Node) @@ -566,6 +602,63 @@ class SchemaTests(TransactionTestCase): editor.alter_field(Author, new_field2, new_field, strict=True) self.assertForeignKeyNotExists(Author, "tag_id", "schema_tag") + @skipUnlessDBFeature("supports_foreign_keys", "can_introspect_foreign_keys") + def test_fk_alter_on_delete(self): + with connection.schema_editor() as editor: + editor.create_model(Author) + editor.create_model(Book) + self.assertForeignKeyExists(Book, "author_id", "schema_author") + # Change CASCADE to DB_CASCADE. + old_field = Book._meta.get_field("author") + new_field = ForeignKey(Author, DB_CASCADE) + new_field.set_attributes_from_name("author") + with ( + connection.schema_editor() as editor, + CaptureQueriesContext(connection) as ctx, + ): + editor.alter_field(Book, old_field, new_field) + self.assertForeignKeyExists(Book, "author_id", "schema_author") + self.assertIs( + any("ON DELETE" in query["sql"] for query in ctx.captured_queries), True + ) + # Change DB_CASCADE to CASCADE. + old_field = new_field + new_field = ForeignKey(Author, CASCADE) + new_field.set_attributes_from_name("author") + with ( + connection.schema_editor() as editor, + CaptureQueriesContext(connection) as ctx, + ): + editor.alter_field(Book, old_field, new_field) + self.assertForeignKeyExists(Book, "author_id", "schema_author") + self.assertIs( + any("ON DELETE" in query["sql"] for query in ctx.captured_queries), False + ) + + @isolate_apps("schema") + @skipUnlessDBFeature("supports_foreign_keys", "can_introspect_foreign_keys") + def test_create_model_db_on_delete(self): + class Parent(Model): + class Meta: + app_label = "schema" + + class Child(Model): + parent_fk = ForeignKey(Parent, DB_SET_NULL, null=True) + + class Meta: + app_label = "schema" + + with connection.schema_editor() as editor: + editor.create_model(Parent) + with CaptureQueriesContext(connection) as ctx: + with connection.schema_editor() as editor: + editor.create_model(Child) + + self.assertForeignKeyNotExists(Child, "parent_id", "schema_parent") + self.assertIs( + any("ON DELETE" in query["sql"] for query in ctx.captured_queries), True + ) + @isolate_apps("schema") def test_no_db_constraint_added_during_primary_key_change(self): """ @@ -4598,6 +4691,7 @@ class SchemaTests(TransactionTestCase): "to_table": editor.quote_name(table), "to_column": editor.quote_name(model._meta.auto_field.column), "deferrable": connection.ops.deferrable_sql(), + "on_delete_db": "", } ) self.assertIn( @@ -4784,7 +4878,7 @@ class SchemaTests(TransactionTestCase): error_messages={"invalid": "error message"}, help_text="help text", limit_choices_to={"limit": "choice"}, - on_delete=PROTECT, + on_delete=CASCADE, related_name="related_name", related_query_name="related_query_name", validators=[lambda x: x], |
