summaryrefslogtreecommitdiff
path: root/django
diff options
context:
space:
mode:
authorMariusz Felisiak <felisiak.mariusz@gmail.com>2025-10-18 15:03:50 +0200
committerGitHub <noreply@github.com>2025-10-18 15:03:50 +0200
commit0c487aa3a7b2417481bf48c1e5355c855873e210 (patch)
tree33b92a3bdf11f66d0f67fe4b4338084c7028c709 /django
parentb1e0262c9f9d11eae6230b51c5aa5d71122d5f05 (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 'django')
-rw-r--r--django/contrib/admin/utils.py11
-rw-r--r--django/contrib/contenttypes/fields.py11
-rw-r--r--django/contrib/contenttypes/management/commands/remove_stale_contenttypes.py12
-rw-r--r--django/db/backends/base/features.py3
-rw-r--r--django/db/backends/base/operations.py10
-rw-r--r--django/db/backends/base/schema.py12
-rw-r--r--django/db/backends/mysql/features.py1
-rw-r--r--django/db/backends/mysql/schema.py2
-rw-r--r--django/db/backends/oracle/features.py1
-rw-r--r--django/db/backends/oracle/schema.py3
-rw-r--r--django/db/backends/postgresql/schema.py4
-rw-r--r--django/db/backends/sqlite3/schema.py3
-rw-r--r--django/db/migrations/serializer.py8
-rw-r--r--django/db/models/__init__.py6
-rw-r--r--django/db/models/base.py26
-rw-r--r--django/db/models/deletion.py39
-rw-r--r--django/db/models/fields/__init__.py2
-rw-r--r--django/db/models/fields/related.py99
18 files changed, 209 insertions, 44 deletions
diff --git a/django/contrib/admin/utils.py b/django/contrib/admin/utils.py
index 74bd571e56..8263b6f9e2 100644
--- a/django/contrib/admin/utils.py
+++ b/django/contrib/admin/utils.py
@@ -184,8 +184,8 @@ def get_deleted_objects(objs, request, admin_site):
class NestedObjects(Collector):
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
+ def __init__(self, *args, force_collection=True, **kwargs):
+ super().__init__(*args, force_collection=force_collection, **kwargs)
self.edges = {} # {from_instance: [to_instances]}
self.protected = set()
self.model_objs = defaultdict(set)
@@ -242,13 +242,6 @@ class NestedObjects(Collector):
roots.extend(self._nested(root, seen, format_callback))
return roots
- def can_fast_delete(self, *args, **kwargs):
- """
- We always want to load the objects into memory so that we can display
- them to the user in confirm page.
- """
- return False
-
def model_format_dict(obj):
"""
diff --git a/django/contrib/contenttypes/fields.py b/django/contrib/contenttypes/fields.py
index 62239dc715..300fec4289 100644
--- a/django/contrib/contenttypes/fields.py
+++ b/django/contrib/contenttypes/fields.py
@@ -10,6 +10,7 @@ from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist
from django.db import DEFAULT_DB_ALIAS, models, router, transaction
from django.db.models import DO_NOTHING, ForeignObject, ForeignObjectRel
from django.db.models.base import ModelBase, make_foreign_order_accessors
+from django.db.models.deletion import DatabaseOnDelete
from django.db.models.fields import Field
from django.db.models.fields.mixins import FieldCacheMixin
from django.db.models.fields.related import (
@@ -139,6 +140,16 @@ class GenericForeignKey(FieldCacheMixin, Field):
id="contenttypes.E004",
)
]
+ elif isinstance(field.remote_field.on_delete, DatabaseOnDelete):
+ return [
+ checks.Error(
+ f"'{self.model._meta.object_name}.{self.ct_field}' cannot use "
+ "the database-level on_delete variant.",
+ hint="Change the on_delete rule to the non-database variant.",
+ obj=self,
+ id="contenttypes.E006",
+ )
+ ]
else:
return []
diff --git a/django/contrib/contenttypes/management/commands/remove_stale_contenttypes.py b/django/contrib/contenttypes/management/commands/remove_stale_contenttypes.py
index 27aaf1d51b..d97a7dec30 100644
--- a/django/contrib/contenttypes/management/commands/remove_stale_contenttypes.py
+++ b/django/contrib/contenttypes/management/commands/remove_stale_contenttypes.py
@@ -61,7 +61,9 @@ class Command(BaseCommand):
ct_info.append(
" - Content type for %s.%s" % (ct.app_label, ct.model)
)
- collector = NoFastDeleteCollector(using=using, origin=ct)
+ collector = Collector(
+ using=using, origin=ct, force_collection=True
+ )
collector.collect([ct])
for obj_type, objs in collector.data.items():
@@ -103,11 +105,3 @@ class Command(BaseCommand):
else:
if verbosity >= 2:
self.stdout.write("Stale content types remain.")
-
-
-class NoFastDeleteCollector(Collector):
- def can_fast_delete(self, *args, **kwargs):
- """
- Always load related objects to display them when showing confirmation.
- """
- return False
diff --git a/django/db/backends/base/features.py b/django/db/backends/base/features.py
index 0c79e5c133..2ada5177be 100644
--- a/django/db/backends/base/features.py
+++ b/django/db/backends/base/features.py
@@ -390,6 +390,9 @@ class BaseDatabaseFeatures:
# subqueries?
supports_tuple_comparison_against_subquery = True
+ # Does the backend support DEFAULT as delete option?
+ supports_on_delete_db_default = True
+
# Collation names for use by the Django test suite.
test_collations = {
"ci": None, # Case-insensitive.
diff --git a/django/db/backends/base/operations.py b/django/db/backends/base/operations.py
index 9822a7fbb1..e345701438 100644
--- a/django/db/backends/base/operations.py
+++ b/django/db/backends/base/operations.py
@@ -254,6 +254,16 @@ class BaseDatabaseOperations:
if sql
)
+ def fk_on_delete_sql(self, operation):
+ """
+ Return the SQL to make an ON DELETE statement.
+ """
+ if operation in ["CASCADE", "SET NULL", "SET DEFAULT"]:
+ return f" ON DELETE {operation}"
+ if operation == "":
+ return ""
+ raise NotImplementedError(f"ON DELETE {operation} is not supported.")
+
def bulk_insert_sql(self, fields, placeholder_rows):
placeholder_rows_sql = (", ".join(row) for row in placeholder_rows)
values_sql = ", ".join([f"({sql})" for sql in placeholder_rows_sql])
diff --git a/django/db/backends/base/schema.py b/django/db/backends/base/schema.py
index 96d555f862..1f27d6a0d4 100644
--- a/django/db/backends/base/schema.py
+++ b/django/db/backends/base/schema.py
@@ -121,7 +121,7 @@ class BaseDatabaseSchemaEditor:
sql_create_fk = (
"ALTER TABLE %(table)s ADD CONSTRAINT %(name)s FOREIGN KEY (%(column)s) "
- "REFERENCES %(to_table)s (%(to_column)s)%(deferrable)s"
+ "REFERENCES %(to_table)s (%(to_column)s)%(on_delete_db)s%(deferrable)s"
)
sql_create_inline_fk = None
sql_create_column_inline_fk = None
@@ -241,6 +241,7 @@ class BaseDatabaseSchemaEditor:
definition += " " + self.sql_create_inline_fk % {
"to_table": self.quote_name(to_table),
"to_column": self.quote_name(to_column),
+ "on_delete_db": self._create_on_delete_sql(model, field),
}
elif self.connection.features.supports_foreign_keys:
self.deferred_sql.append(
@@ -759,6 +760,7 @@ class BaseDatabaseSchemaEditor:
"to_table": self.quote_name(to_table),
"to_column": self.quote_name(to_column),
"deferrable": self.connection.ops.deferrable_sql(),
+ "on_delete_db": self._create_on_delete_sql(model, field),
}
# Otherwise, add FK constraints later.
else:
@@ -1628,6 +1630,13 @@ class BaseDatabaseSchemaEditor:
new_name=self.quote_name(new_name),
)
+ def _create_on_delete_sql(self, model, field):
+ remote_field = field.remote_field
+ try:
+ return remote_field.on_delete.on_delete_sql(self)
+ except AttributeError:
+ return ""
+
def _index_columns(self, table, columns, col_suffixes, opclasses):
return Columns(table, columns, self.quote_name, col_suffixes=col_suffixes)
@@ -1740,6 +1749,7 @@ class BaseDatabaseSchemaEditor:
to_table=to_table,
to_column=to_column,
deferrable=deferrable,
+ on_delete_db=self._create_on_delete_sql(model, field),
)
def _fk_constraint_name(self, model, field, suffix):
diff --git a/django/db/backends/mysql/features.py b/django/db/backends/mysql/features.py
index 24ecc0d80b..4be20b92ac 100644
--- a/django/db/backends/mysql/features.py
+++ b/django/db/backends/mysql/features.py
@@ -44,6 +44,7 @@ class DatabaseFeatures(BaseDatabaseFeatures):
SET V_I = P_I;
END;
"""
+ supports_on_delete_db_default = False
# Neither MySQL nor MariaDB support partial indexes.
supports_partial_indexes = False
# COLLATE must be wrapped in parentheses because MySQL treats COLLATE as an
diff --git a/django/db/backends/mysql/schema.py b/django/db/backends/mysql/schema.py
index a4dba0ad39..ab388754ed 100644
--- a/django/db/backends/mysql/schema.py
+++ b/django/db/backends/mysql/schema.py
@@ -14,7 +14,7 @@ class DatabaseSchemaEditor(BaseDatabaseSchemaEditor):
sql_delete_unique = "ALTER TABLE %(table)s DROP INDEX %(name)s"
sql_create_column_inline_fk = (
", ADD CONSTRAINT %(name)s FOREIGN KEY (%(column)s) "
- "REFERENCES %(to_table)s(%(to_column)s)"
+ "REFERENCES %(to_table)s(%(to_column)s)%(on_delete_db)s"
)
sql_delete_fk = "ALTER TABLE %(table)s DROP FOREIGN KEY %(name)s"
diff --git a/django/db/backends/oracle/features.py b/django/db/backends/oracle/features.py
index e87f495e5c..c07d9f1ed0 100644
--- a/django/db/backends/oracle/features.py
+++ b/django/db/backends/oracle/features.py
@@ -78,6 +78,7 @@ class DatabaseFeatures(BaseDatabaseFeatures):
supports_json_field_contains = False
supports_json_negative_indexing = False
supports_collation_on_textfield = False
+ supports_on_delete_db_default = False
test_now_utc_template = "CURRENT_TIMESTAMP AT TIME ZONE 'UTC'"
django_test_expected_failures = {
# A bug in Django/oracledb with respect to string handling (#23843).
diff --git a/django/db/backends/oracle/schema.py b/django/db/backends/oracle/schema.py
index 48a048575d..13fa7220ce 100644
--- a/django/db/backends/oracle/schema.py
+++ b/django/db/backends/oracle/schema.py
@@ -20,7 +20,8 @@ class DatabaseSchemaEditor(BaseDatabaseSchemaEditor):
sql_alter_column_no_default_null = sql_alter_column_no_default
sql_create_column_inline_fk = (
- "CONSTRAINT %(name)s REFERENCES %(to_table)s(%(to_column)s)%(deferrable)s"
+ "CONSTRAINT %(name)s REFERENCES %(to_table)s(%(to_column)s)%(on_delete_db)"
+ "s%(deferrable)s"
)
sql_delete_table = "DROP TABLE %(table)s CASCADE CONSTRAINTS"
sql_create_index = "CREATE INDEX %(name)s ON %(table)s (%(columns)s)%(extra)s"
diff --git a/django/db/backends/postgresql/schema.py b/django/db/backends/postgresql/schema.py
index 1d36696fd3..7dd9161687 100644
--- a/django/db/backends/postgresql/schema.py
+++ b/django/db/backends/postgresql/schema.py
@@ -28,8 +28,8 @@ class DatabaseSchemaEditor(BaseDatabaseSchemaEditor):
# Setting the constraint to IMMEDIATE to allow changing data in the same
# transaction.
sql_create_column_inline_fk = (
- "CONSTRAINT %(name)s REFERENCES %(to_table)s(%(to_column)s)%(deferrable)s"
- "; SET CONSTRAINTS %(namespace)s%(name)s IMMEDIATE"
+ "CONSTRAINT %(name)s REFERENCES %(to_table)s(%(to_column)s)%(on_delete_db)s"
+ "%(deferrable)s; SET CONSTRAINTS %(namespace)s%(name)s IMMEDIATE"
)
# Setting the constraint to IMMEDIATE runs any deferred checks to allow
# dropping it in the same transaction.
diff --git a/django/db/backends/sqlite3/schema.py b/django/db/backends/sqlite3/schema.py
index 077a53bf55..223a70947b 100644
--- a/django/db/backends/sqlite3/schema.py
+++ b/django/db/backends/sqlite3/schema.py
@@ -13,7 +13,8 @@ class DatabaseSchemaEditor(BaseDatabaseSchemaEditor):
sql_delete_table = "DROP TABLE %(table)s"
sql_create_fk = None
sql_create_inline_fk = (
- "REFERENCES %(to_table)s (%(to_column)s) DEFERRABLE INITIALLY DEFERRED"
+ "REFERENCES %(to_table)s (%(to_column)s)%(on_delete_db)s DEFERRABLE INITIALLY "
+ "DEFERRED"
)
sql_create_column_inline_fk = sql_create_inline_fk
sql_create_unique = "CREATE UNIQUE INDEX %(name)s ON %(table)s (%(columns)s)"
diff --git a/django/db/migrations/serializer.py b/django/db/migrations/serializer.py
index 8366fb0a42..013bb0fb00 100644
--- a/django/db/migrations/serializer.py
+++ b/django/db/migrations/serializer.py
@@ -16,6 +16,7 @@ from django.conf import SettingsReference
from django.db import models
from django.db.migrations.operations.base import Operation
from django.db.migrations.utils import COMPILED_REGEX_TYPE, RegexObject
+from django.db.models.deletion import DatabaseOnDelete
from django.utils.functional import LazyObject, Promise
from django.utils.version import get_docs_version
@@ -71,6 +72,12 @@ class ChoicesSerializer(BaseSerializer):
return serializer_factory(self.value.value).serialize()
+class DatabaseOnDeleteSerializer(BaseSerializer):
+ def serialize(self):
+ path = self.value.__class__.__module__
+ return f"{path}.{self.value.__name__}", {f"import {path}"}
+
+
class DateTimeSerializer(BaseSerializer):
"""For datetime.*, except datetime.datetime."""
@@ -363,6 +370,7 @@ class Serializer:
pathlib.PurePath: PathSerializer,
os.PathLike: PathLikeSerializer,
zoneinfo.ZoneInfo: ZoneInfoSerializer,
+ DatabaseOnDelete: DatabaseOnDeleteSerializer,
}
@classmethod
diff --git a/django/db/models/__init__.py b/django/db/models/__init__.py
index f15ddecfaa..757e098317 100644
--- a/django/db/models/__init__.py
+++ b/django/db/models/__init__.py
@@ -6,6 +6,9 @@ from django.db.models.constraints import * # NOQA
from django.db.models.constraints import __all__ as constraints_all
from django.db.models.deletion import (
CASCADE,
+ DB_CASCADE,
+ DB_SET_DEFAULT,
+ DB_SET_NULL,
DO_NOTHING,
PROTECT,
RESTRICT,
@@ -75,6 +78,9 @@ __all__ += [
"ObjectDoesNotExist",
"signals",
"CASCADE",
+ "DB_CASCADE",
+ "DB_SET_DEFAULT",
+ "DB_SET_NULL",
"DO_NOTHING",
"PROTECT",
"RESTRICT",
diff --git a/django/db/models/base.py b/django/db/models/base.py
index b92a198660..b58e7e3e52 100644
--- a/django/db/models/base.py
+++ b/django/db/models/base.py
@@ -30,7 +30,7 @@ from django.db import (
)
from django.db.models import NOT_PROVIDED, ExpressionWrapper, IntegerField, Max, Value
from django.db.models.constants import LOOKUP_SEP
-from django.db.models.deletion import CASCADE, Collector
+from django.db.models.deletion import CASCADE, DO_NOTHING, Collector, DatabaseOnDelete
from django.db.models.expressions import DatabaseDefault
from django.db.models.fetch_modes import FETCH_ONE
from django.db.models.fields.composite import CompositePrimaryKey
@@ -1770,6 +1770,7 @@ class Model(AltersData, metaclass=ModelBase):
*cls._check_fields(**kwargs),
*cls._check_m2m_through_same_relationship(),
*cls._check_long_column_names(databases),
+ *cls._check_related_fields(),
]
clash_errors = (
*cls._check_id_field(),
@@ -2456,6 +2457,29 @@ class Model(AltersData, metaclass=ModelBase):
return errors
@classmethod
+ def _check_related_fields(cls):
+ has_db_variant = False
+ has_python_variant = False
+ for rel in cls._meta.get_fields():
+ if rel.related_model:
+ if not (on_delete := getattr(rel.remote_field, "on_delete", None)):
+ continue
+ if isinstance(on_delete, DatabaseOnDelete):
+ has_db_variant = True
+ elif on_delete != DO_NOTHING:
+ has_python_variant = True
+ if has_db_variant and has_python_variant:
+ return [
+ checks.Error(
+ "The model cannot have related fields with both "
+ "database-level and Python-level on_delete variants.",
+ obj=cls,
+ id="models.E050",
+ )
+ ]
+ return []
+
+ @classmethod
def _get_expr_references(cls, expr):
if isinstance(expr, Q):
for child in expr.children:
diff --git a/django/db/models/deletion.py b/django/db/models/deletion.py
index 8d3fa5c92c..c42c7e9861 100644
--- a/django/db/models/deletion.py
+++ b/django/db/models/deletion.py
@@ -81,6 +81,28 @@ def DO_NOTHING(collector, field, sub_objs, using):
pass
+class DatabaseOnDelete:
+ def __init__(self, operation, name, forced_collector=None):
+ self.operation = operation
+ self.forced_collector = forced_collector
+ self.__name__ = name
+
+ __call__ = DO_NOTHING
+
+ def on_delete_sql(self, schema_editor):
+ return schema_editor.connection.ops.fk_on_delete_sql(self.operation)
+
+ def __str__(self):
+ return self.__name__
+
+
+DB_CASCADE = DatabaseOnDelete("CASCADE", "DB_CASCADE", CASCADE)
+DB_SET_DEFAULT = DatabaseOnDelete("SET DEFAULT", "DB_SET_DEFAULT")
+DB_SET_NULL = DatabaseOnDelete("SET NULL", "DB_SET_NULL")
+
+SKIP_COLLECTION = frozenset([DO_NOTHING, DB_CASCADE, DB_SET_DEFAULT, DB_SET_NULL])
+
+
def get_candidate_relations_to_delete(opts):
# The candidate relations are the ones that come from N-1 and 1-1
# relations. N-N (i.e., many-to-many) relations aren't candidates for
@@ -93,10 +115,12 @@ def get_candidate_relations_to_delete(opts):
class Collector:
- def __init__(self, using, origin=None):
+ def __init__(self, using, origin=None, force_collection=False):
self.using = using
# A Model or QuerySet object.
self.origin = origin
+ # Force collecting objects for deletion on the Python-level.
+ self.force_collection = force_collection
# Initially, {model: {instances}}, later values become lists.
self.data = defaultdict(set)
# {(field, value): [instances, …]}
@@ -194,6 +218,8 @@ class Collector:
skipping parent -> child -> parent chain preventing fast delete of
the child.
"""
+ if self.force_collection:
+ return False
if from_field and from_field.remote_field.on_delete is not CASCADE:
return False
if hasattr(objs, "_meta"):
@@ -215,7 +241,7 @@ class Collector:
and
# Foreign keys pointing to this model.
all(
- related.field.remote_field.on_delete is DO_NOTHING
+ related.field.remote_field.on_delete in SKIP_COLLECTION
for related in get_candidate_relations_to_delete(opts)
)
and (
@@ -316,8 +342,13 @@ class Collector:
continue
field = related.field
on_delete = field.remote_field.on_delete
- if on_delete == DO_NOTHING:
- continue
+ if on_delete in SKIP_COLLECTION:
+ if self.force_collection and (
+ forced_on_delete := getattr(on_delete, "forced_collector", None)
+ ):
+ on_delete = forced_on_delete
+ else:
+ continue
related_model = related.related_model
if self.can_fast_delete(related_model, from_field=field):
model_fast_deletes[related_model].append(field)
diff --git a/django/db/models/fields/__init__.py b/django/db/models/fields/__init__.py
index f12ae97968..3e2258e064 100644
--- a/django/db/models/fields/__init__.py
+++ b/django/db/models/fields/__init__.py
@@ -155,8 +155,6 @@ class Field(RegisterLookupMixin):
"error_messages",
"help_text",
"limit_choices_to",
- # Database-level options are not supported, see #21961.
- "on_delete",
"related_name",
"related_query_name",
"validators",
diff --git a/django/db/models/fields/related.py b/django/db/models/fields/related.py
index a71ae2f401..0293c78909 100644
--- a/django/db/models/fields/related.py
+++ b/django/db/models/fields/related.py
@@ -6,11 +6,19 @@ from django import forms
from django.apps import apps
from django.conf import SettingsReference, settings
from django.core import checks, exceptions
-from django.db import connection, router
+from django.db import connection, connections, router
from django.db.backends import utils
-from django.db.models import Q
+from django.db.models import NOT_PROVIDED, Q
from django.db.models.constants import LOOKUP_SEP
-from django.db.models.deletion import CASCADE, SET_DEFAULT, SET_NULL
+from django.db.models.deletion import (
+ CASCADE,
+ DB_SET_DEFAULT,
+ DB_SET_NULL,
+ DO_NOTHING,
+ SET_DEFAULT,
+ SET_NULL,
+ DatabaseOnDelete,
+)
from django.db.models.query_utils import PathInfo
from django.db.models.utils import make_model_tuple
from django.utils.functional import cached_property
@@ -1041,18 +1049,21 @@ class ForeignKey(ForeignObject):
return cls
def check(self, **kwargs):
+ databases = kwargs.get("databases") or []
return [
*super().check(**kwargs),
- *self._check_on_delete(),
+ *self._check_on_delete(databases),
*self._check_unique(),
]
- def _check_on_delete(self):
+ def _check_on_delete(self, databases):
on_delete = getattr(self.remote_field, "on_delete", None)
- if on_delete == SET_NULL and not self.null:
- return [
+ errors = []
+ if on_delete in [DB_SET_NULL, SET_NULL] and not self.null:
+ errors.append(
checks.Error(
- "Field specifies on_delete=SET_NULL, but cannot be null.",
+ f"Field specifies on_delete={on_delete.__name__}, but cannot be "
+ "null.",
hint=(
"Set null=True argument on the field, or change the on_delete "
"rule."
@@ -1060,18 +1071,80 @@ class ForeignKey(ForeignObject):
obj=self,
id="fields.E320",
)
- ]
+ )
elif on_delete == SET_DEFAULT and not self.has_default():
- return [
+ errors.append(
checks.Error(
"Field specifies on_delete=SET_DEFAULT, but has no default value.",
hint="Set a default value, or change the on_delete rule.",
obj=self,
id="fields.E321",
)
- ]
- else:
- return []
+ )
+ elif on_delete == DB_SET_DEFAULT:
+ if self.db_default is NOT_PROVIDED:
+ errors.append(
+ checks.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=self,
+ 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:
+ # Database and Python variants cannot be mixed in a chain of
+ # model references.
+ is_db_on_delete = isinstance(on_delete, DatabaseOnDelete)
+ ref_model_related_fields = (
+ ref_model_field.remote_field
+ for ref_model_field in self.remote_field.model._meta.get_fields()
+ if ref_model_field.related_model
+ and hasattr(ref_model_field.remote_field, "on_delete")
+ )
+
+ for ref_remote_field in ref_model_related_fields:
+ if (
+ ref_remote_field.on_delete is not None
+ and ref_remote_field.on_delete != DO_NOTHING
+ and isinstance(ref_remote_field.on_delete, DatabaseOnDelete)
+ is not is_db_on_delete
+ ):
+ on_delete_type = "database" if is_db_on_delete else "Python"
+ ref_on_delete_type = "Python" if is_db_on_delete else "database"
+ errors.append(
+ checks.Error(
+ f"Field specifies {on_delete_type}-level on_delete "
+ "variant, but referenced model uses "
+ f"{ref_on_delete_type}-level variant.",
+ hint=(
+ "Use either database or Python on_delete variants "
+ "uniformly in the references chain."
+ ),
+ obj=self,
+ id="fields.E323",
+ )
+ )
+ break
+ return errors
def _check_unique(self, **kwargs):
return (