summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTim Graham <timograham@gmail.com>2025-11-20 20:18:09 -0500
committerMariusz Felisiak <felisiak.mariusz@gmail.com>2025-11-23 11:53:24 +0100
commit57c50d8c1996733cef45204ea069a2c01b2860cc (patch)
treeef25923bfc179acb84d09c934fe50ff565a6a929
parentec60df6d1ea8939a316d9b180faa0b4ef2e83606 (diff)
Refs #21961 -- Added DatabaseFeatures.supports_on_delete_db_(cascade/null) feature flags.
Needed on MongoDB. Co-Authored-By: Mariusz Felisiak <felisiak.mariusz@gmail.com>
-rw-r--r--django/db/backends/base/features.py4
-rw-r--r--django/db/models/fields/related.py55
-rw-r--r--docs/ref/checks.txt3
-rw-r--r--tests/admin_utils/models.py3
-rw-r--r--tests/admin_utils/tests.py3
-rw-r--r--tests/delete/models.py26
-rw-r--r--tests/delete/tests.py18
-rw-r--r--tests/inspectdb/models.py6
-rw-r--r--tests/inspectdb/tests.py6
-rw-r--r--tests/introspection/models.py11
-rw-r--r--tests/introspection/tests.py21
-rw-r--r--tests/invalid_models_tests/test_relative_fields.py73
12 files changed, 181 insertions, 48 deletions
diff --git a/django/db/backends/base/features.py b/django/db/backends/base/features.py
index ecc283ff6b..1e957d105e 100644
--- a/django/db/backends/base/features.py
+++ b/django/db/backends/base/features.py
@@ -393,8 +393,10 @@ class BaseDatabaseFeatures:
# subqueries?
supports_tuple_comparison_against_subquery = True
- # Does the backend support DEFAULT as delete option?
+ # Does the backend support CASCADE, DEFAULT, NULL as delete options?
+ supports_on_delete_db_cascade = True
supports_on_delete_db_default = True
+ supports_on_delete_db_null = True
# Collation names for use by the Django test suite.
test_collations = {
diff --git a/django/db/models/fields/related.py b/django/db/models/fields/related.py
index 0293c78909..f1bc664007 100644
--- a/django/db/models/fields/related.py
+++ b/django/db/models/fields/related.py
@@ -12,6 +12,7 @@ from django.db.models import NOT_PROVIDED, Q
from django.db.models.constants import LOOKUP_SEP
from django.db.models.deletion import (
CASCADE,
+ DB_CASCADE,
DB_SET_DEFAULT,
DB_SET_NULL,
DO_NOTHING,
@@ -1056,9 +1057,38 @@ class ForeignKey(ForeignObject):
*self._check_unique(),
]
+ def _check_on_delete_db_support(self, on_delete, feature_flag, databases):
+ for db in databases:
+ if not router.allow_migrate_model(db, self.model):
+ continue
+ connection = connections[db]
+ if feature_flag in self.model._meta.required_db_features or getattr(
+ connection.features, feature_flag
+ ):
+ continue
+ no_db_option_name = on_delete.__name__.removeprefix("DB_")
+ yield checks.Error(
+ f"{connection.display_name} does not support a {on_delete.__name__}.",
+ hint=f"Change the on_delete rule to {no_db_option_name}.",
+ obj=self,
+ id="fields.E324",
+ )
+
def _check_on_delete(self, databases):
on_delete = getattr(self.remote_field, "on_delete", None)
errors = []
+ if on_delete == DB_CASCADE:
+ errors.extend(
+ self._check_on_delete_db_support(
+ on_delete, "supports_on_delete_db_cascade", databases
+ )
+ )
+ if on_delete == DB_SET_NULL:
+ errors.extend(
+ self._check_on_delete_db_support(
+ on_delete, "supports_on_delete_db_null", databases
+ )
+ )
if on_delete in [DB_SET_NULL, SET_NULL] and not self.null:
errors.append(
checks.Error(
@@ -1092,25 +1122,12 @@ class ForeignKey(ForeignObject):
id="fields.E322",
)
)
- for db in databases:
- if not router.allow_migrate_model(db, self.model):
- continue
- connection = connections[db]
- if not (
- "supports_on_delete_db_default"
- in self.model._meta.required_db_features
- or connection.features.supports_on_delete_db_default
- ):
- errors.append(
- checks.Error(
- f"{connection.display_name} does not support a "
- "DB_SET_DEFAULT.",
- hint="Change the on_delete rule to SET_DEFAULT.",
- obj=self,
- id="fields.E324",
- ),
- )
- elif not isinstance(self.remote_field.model, str) and on_delete != DO_NOTHING:
+ errors.extend(
+ self._check_on_delete_db_support(
+ on_delete, "supports_on_delete_db_default", databases
+ )
+ )
+ if not isinstance(self.remote_field.model, str) and on_delete != DO_NOTHING:
# Database and Python variants cannot be mixed in a chain of
# model references.
is_db_on_delete = isinstance(on_delete, DatabaseOnDelete)
diff --git a/docs/ref/checks.txt b/docs/ref/checks.txt
index 22ebfed2ca..7dfa7c61d2 100644
--- a/docs/ref/checks.txt
+++ b/docs/ref/checks.txt
@@ -309,7 +309,8 @@ Related fields
``db_default`` value.
* **fields.E323**: Field specifies database/Python-level on_delete variant, but
referenced model uses python/database-level variant.
-* **fields.E324**: ``<database>`` does not support ``DB_SET_DEFAULT``.
+* **fields.E324**: ``<database>`` does not support
+ ``<database delete option>``.
* **fields.E330**: ``ManyToManyField``\s cannot be unique.
* **fields.E331**: Field specifies a many-to-many relation through model
``<model>``, which has not been installed.
diff --git a/tests/admin_utils/models.py b/tests/admin_utils/models.py
index e5d2b67887..55810f0b50 100644
--- a/tests/admin_utils/models.py
+++ b/tests/admin_utils/models.py
@@ -55,6 +55,9 @@ class DBCascade(models.Model):
def __str__(self):
return str(self.num)
+ class Meta:
+ required_db_features = {"supports_on_delete_db_cascade"}
+
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 ce32535c52..81c6f495f8 100644
--- a/tests/admin_utils/tests.py
+++ b/tests/admin_utils/tests.py
@@ -21,7 +21,7 @@ from django.contrib.auth.models import User
from django.contrib.auth.templatetags.auth import render_password_as_hash
from django.core.validators import EMPTY_VALUES
from django.db import DEFAULT_DB_ALIAS, models
-from django.test import SimpleTestCase, TestCase, override_settings
+from django.test import SimpleTestCase, TestCase, override_settings, skipUnlessDBFeature
from django.test.utils import isolate_apps
from django.utils.formats import localize
from django.utils.safestring import mark_safe
@@ -115,6 +115,7 @@ class NestedObjectsTests(TestCase):
n.collect([Vehicle.objects.first()])
+@skipUnlessDBFeature("supports_on_delete_db_cascade")
class DBNestedObjectsTests(NestedObjectsTests):
"""
Exercise NestedObjectsTests but with a model that makes use of DB_CASCADE
diff --git a/tests/delete/models.py b/tests/delete/models.py
index bd9caf42a7..d8f81827be 100644
--- a/tests/delete/models.py
+++ b/tests/delete/models.py
@@ -49,17 +49,26 @@ class RelatedDbOptionParent(models.Model):
p = models.ForeignKey(RelatedDbOptionGrandParent, models.DB_CASCADE, null=True)
-class RelatedDbOption(models.Model):
+class CascadeDbModel(models.Model):
name = models.CharField(max_length=30)
+ db_cascade = models.ForeignKey(
+ RelatedDbOptionParent, models.DB_CASCADE, related_name="db_cascade_set"
+ )
+
+ class Meta:
+ required_db_features = {"supports_on_delete_db_cascade"}
+
+
+class SetNullDbModel(models.Model):
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 Meta:
+ required_db_features = {"supports_on_delete_db_null"}
class SetDefaultDbModel(models.Model):
@@ -159,15 +168,6 @@ 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 8d525d1e5f..664cdfb440 100644
--- a/tests/delete/tests.py
+++ b/tests/delete/tests.py
@@ -15,6 +15,7 @@ from .models import (
Avatar,
B,
Base,
+ CascadeDbModel,
Child,
DeleteBottom,
DeleteTop,
@@ -34,16 +35,15 @@ from .models import (
RChild,
RChildChild,
Referrer,
- RelatedDbOption,
RelatedDbOptionGrandParent,
RelatedDbOptionParent,
RProxy,
S,
SetDefaultDbModel,
+ SetNullDbModel,
T,
User,
create_a,
- create_related_db_option,
get_default_r,
)
@@ -81,10 +81,13 @@ class OnDeleteTests(TestCase):
a = A.objects.get(pk=a.pk)
self.assertIsNone(a.setnull)
+ @skipUnlessDBFeature("supports_on_delete_db_null")
def test_db_setnull(self):
- a = create_related_db_option("db_setnull")
+ a = SetNullDbModel.objects.create(
+ db_setnull=RelatedDbOptionParent.objects.create()
+ )
a.db_setnull.delete()
- a = RelatedDbOption.objects.get(pk=a.pk)
+ a = SetNullDbModel.objects.get(pk=a.pk)
self.assertIsNone(a.db_setnull)
def test_setdefault(self):
@@ -394,20 +397,21 @@ class DeletionTests(TestCase):
self.assertNumQueries(5, s.delete)
self.assertFalse(S.objects.exists())
+ @skipUnlessDBFeature("supports_on_delete_db_cascade")
def test_db_cascade(self):
related_db_op = RelatedDbOptionParent.objects.create(
p=RelatedDbOptionGrandParent.objects.create()
)
- RelatedDbOption.objects.bulk_create(
+ CascadeDbModel.objects.bulk_create(
[
- RelatedDbOption(db_cascade=related_db_op)
+ CascadeDbModel(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(CascadeDbModel.objects.exists())
self.assertFalse(RelatedDbOptionParent.objects.exists())
def test_instance_update(self):
diff --git a/tests/inspectdb/models.py b/tests/inspectdb/models.py
index fbe1df8a95..489704879f 100644
--- a/tests/inspectdb/models.py
+++ b/tests/inspectdb/models.py
@@ -178,3 +178,9 @@ class DbOnDeleteModel(models.Model):
fk_set_null = models.ForeignKey(
DigitsInColumnName, on_delete=models.DB_SET_NULL, null=True
)
+
+ class Meta:
+ required_db_features = {
+ "supports_on_delete_db_cascade",
+ "supports_on_delete_db_null",
+ }
diff --git a/tests/inspectdb/tests.py b/tests/inspectdb/tests.py
index c16258b0eb..8175c52e4e 100644
--- a/tests/inspectdb/tests.py
+++ b/tests/inspectdb/tests.py
@@ -301,7 +301,11 @@ class InspectDBTestCase(TestCase):
out.getvalue(),
)
- @skipUnlessDBFeature("can_introspect_foreign_keys")
+ @skipUnlessDBFeature(
+ "can_introspect_foreign_keys",
+ "supports_on_delete_db_cascade",
+ "supports_on_delete_db_null",
+ )
def test_foreign_key_db_on_delete(self):
out = StringIO()
call_command("inspectdb", "inspectdb_dbondeletemodel", stdout=out)
diff --git a/tests/introspection/models.py b/tests/introspection/models.py
index 6c94f5212c..8ced619b08 100644
--- a/tests/introspection/models.py
+++ b/tests/introspection/models.py
@@ -112,11 +112,20 @@ class DbCommentModel(models.Model):
required_db_features = {"supports_comments"}
-class DbOnDeleteModel(models.Model):
+class DbOnDeleteCascadeModel(models.Model):
fk_do_nothing = models.ForeignKey(Country, on_delete=models.DO_NOTHING)
fk_db_cascade = models.ForeignKey(City, on_delete=models.DB_CASCADE)
+
+ class Meta:
+ required_db_features = {"supports_on_delete_db_cascade"}
+
+
+class DbOnDeleteSetNullModel(models.Model):
fk_set_null = models.ForeignKey(Reporter, on_delete=models.DB_SET_NULL, null=True)
+ class Meta:
+ required_db_features = {"supports_on_delete_db_null"}
+
class DbOnDeleteSetDefaultModel(models.Model):
fk_db_set_default = models.ForeignKey(
diff --git a/tests/introspection/tests.py b/tests/introspection/tests.py
index 1f7f22b2dc..3a2c6112c8 100644
--- a/tests/introspection/tests.py
+++ b/tests/introspection/tests.py
@@ -10,8 +10,9 @@ from .models import (
Comment,
Country,
DbCommentModel,
- DbOnDeleteModel,
+ DbOnDeleteCascadeModel,
DbOnDeleteSetDefaultModel,
+ DbOnDeleteSetNullModel,
District,
Reporter,
UniqueConstraintConditionModel,
@@ -244,11 +245,11 @@ class IntrospectionTests(TransactionTestCase):
editor.add_field(Article, body)
self.assertEqual(relations, expected_relations)
- @skipUnlessDBFeature("can_introspect_foreign_keys")
- def test_get_relations_db_on_delete(self):
+ @skipUnlessDBFeature("can_introspect_foreign_keys", "supports_on_delete_db_cascade")
+ def test_get_relations_db_on_delete_cascade(self):
with connection.cursor() as cursor:
relations = connection.introspection.get_relations(
- cursor, DbOnDeleteModel._meta.db_table
+ cursor, DbOnDeleteCascadeModel._meta.db_table
)
if connection.vendor == "mysql" and connection.mysql_is_mariadb:
@@ -259,6 +260,18 @@ class IntrospectionTests(TransactionTestCase):
expected_relations = {
"fk_db_cascade_id": ("id", City._meta.db_table, DB_CASCADE),
"fk_do_nothing_id": ("id", Country._meta.db_table, no_db_on_delete),
+ }
+ self.assertEqual(relations, expected_relations)
+
+ @skipUnlessDBFeature("can_introspect_foreign_keys", "supports_on_delete_db_null")
+ def test_get_relations_db_on_delete_null(self):
+ with connection.cursor() as cursor:
+ relations = connection.introspection.get_relations(
+ cursor, DbOnDeleteSetNullModel._meta.db_table
+ )
+
+ # {field_name: (field_name_other_table, other_table, db_on_delete)}
+ expected_relations = {
"fk_set_null_id": ("id", Reporter._meta.db_table, DB_SET_NULL),
}
self.assertEqual(relations, expected_relations)
diff --git a/tests/invalid_models_tests/test_relative_fields.py b/tests/invalid_models_tests/test_relative_fields.py
index e73f22ab41..51b1de1494 100644
--- a/tests/invalid_models_tests/test_relative_fields.py
+++ b/tests/invalid_models_tests/test_relative_fields.py
@@ -2288,6 +2288,41 @@ class M2mThroughFieldsTests(SimpleTestCase):
@isolate_apps("invalid_models_tests")
class DatabaseLevelOnDeleteTests(TestCase):
+ def test_db_cascade_support(self):
+ class Parent(models.Model):
+ pass
+
+ class Child(models.Model):
+ parent = models.ForeignKey(Parent, models.DB_CASCADE)
+
+ field = Child._meta.get_field("parent")
+ expected = (
+ []
+ if connection.features.supports_on_delete_db_cascade
+ else [
+ Error(
+ f"{connection.display_name} does not support a DB_CASCADE.",
+ hint="Change the on_delete rule to CASCADE.",
+ obj=field,
+ id="fields.E324",
+ )
+ ]
+ )
+ self.assertEqual(field.check(databases=self.databases), expected)
+
+ def test_db_cascade_required_db_features(self):
+ class Parent(models.Model):
+ pass
+
+ class Child(models.Model):
+ parent = models.ForeignKey(Parent, models.DB_CASCADE)
+
+ class Meta:
+ required_db_features = {"supports_on_delete_db_cascade"}
+
+ field = Child._meta.get_field("parent")
+ self.assertEqual(field.check(databases=self.databases), [])
+
def test_db_set_default_support(self):
class Parent(models.Model):
pass
@@ -2349,6 +2384,41 @@ class DatabaseLevelOnDeleteTests(TestCase):
],
)
+ def test_db_set_null_support(self):
+ class Parent(models.Model):
+ pass
+
+ class Child(models.Model):
+ parent = models.ForeignKey(Parent, models.DB_SET_NULL, null=True)
+
+ field = Child._meta.get_field("parent")
+ expected = (
+ []
+ if connection.features.supports_on_delete_db_null
+ else [
+ Error(
+ f"{connection.display_name} does not support a DB_SET_NULL.",
+ hint="Change the on_delete rule to SET_NULL.",
+ obj=field,
+ id="fields.E324",
+ )
+ ]
+ )
+ self.assertEqual(field.check(databases=self.databases), expected)
+
+ def test_db_set_null_required_db_features(self):
+ class Parent(models.Model):
+ pass
+
+ class Child(models.Model):
+ parent = models.ForeignKey(Parent, models.DB_SET_NULL, null=True)
+
+ class Meta:
+ required_db_features = {"supports_on_delete_db_null"}
+
+ field = Child._meta.get_field("parent")
+ self.assertEqual(field.check(databases=self.databases), [])
+
def test_python_db_chain(self):
class GrandParent(models.Model):
pass
@@ -2376,6 +2446,7 @@ class DatabaseLevelOnDeleteTests(TestCase):
],
)
+ @skipUnlessDBFeature("supports_on_delete_db_null")
def test_db_python_chain(self):
class GrandParent(models.Model):
pass
@@ -2403,6 +2474,7 @@ class DatabaseLevelOnDeleteTests(TestCase):
],
)
+ @skipUnlessDBFeature("supports_on_delete_db_cascade")
def test_db_python_chain_auto_created(self):
class GrandParent(models.Model):
pass
@@ -2430,6 +2502,7 @@ class DatabaseLevelOnDeleteTests(TestCase):
],
)
+ @skipUnlessDBFeature("supports_on_delete_db_null")
def test_db_do_nothing_chain(self):
class GrandParent(models.Model):
pass