summaryrefslogtreecommitdiff
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
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>
-rw-r--r--AUTHORS1
-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
-rw-r--r--docs/ref/checks.txt16
-rw-r--r--docs/ref/models/fields.txt53
-rw-r--r--docs/ref/models/instances.txt10
-rw-r--r--docs/ref/models/querysets.txt29
-rw-r--r--docs/releases/6.1.txt15
-rw-r--r--tests/admin_utils/models.py10
-rw-r--r--tests/admin_utils/tests.py25
-rw-r--r--tests/contenttypes_tests/test_checks.py21
-rw-r--r--tests/delete/models.py49
-rw-r--r--tests/delete/tests.py51
-rw-r--r--tests/invalid_models_tests/test_models.py61
-rw-r--r--tests/invalid_models_tests/test_relative_fields.py198
-rw-r--r--tests/migrations/test_writer.py17
-rw-r--r--tests/schema/tests.py96
33 files changed, 838 insertions, 67 deletions
diff --git a/AUTHORS b/AUTHORS
index 4204dc9f2b..be55e379c2 100644
--- a/AUTHORS
+++ b/AUTHORS
@@ -791,6 +791,7 @@ answer newbie questions, and generally made Django that much better:
Nick Presta <nick@nickpresta.ca>
Nick Sandford <nick.sandford@gmail.com>
Nick Sarbicki <nick.a.sarbicki@gmail.com>
+ Nick Stefan <https://github.com/nickstefan>
Niclas Olofsson <n@niclasolofsson.se>
Nicola Larosa <nico@teknico.net>
Nicolas Lara <nicolaslara@gmail.com>
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 (
diff --git a/docs/ref/checks.txt b/docs/ref/checks.txt
index 7735eed478..c297938f45 100644
--- a/docs/ref/checks.txt
+++ b/docs/ref/checks.txt
@@ -299,9 +299,15 @@ Related fields
referenced by a ``ForeignKey``.
* **fields.E312**: The ``to_field`` ``<field name>`` doesn't exist on the
related model ``<app label>.<model>``.
-* **fields.E320**: Field specifies ``on_delete=SET_NULL``, but cannot be null.
-* **fields.E321**: The field specifies ``on_delete=SET_DEFAULT``, but has no
- default value.
+* **fields.E320**: Field specifies ``on_delete=<set null option>``, but cannot
+ be null.
+* **fields.E321**: Field specifies ``on_delete=SET_DEFAULT``, but has no
+ ``default`` value.
+* **fields.E322**: Field specifies ``on_delete=DB_SET_DEFAULT``, but has no
+ ``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.E330**: ``ManyToManyField``\s cannot be unique.
* **fields.E331**: Field specifies a many-to-many relation through model
``<model>``, which has not been installed.
@@ -446,6 +452,8 @@ Models
* **models.E049**: ``constraints/indexes/unique_together`` refers to a
``ForeignObject`` ``<field name>`` with multiple ``from_fields``, which is
not supported for that option.
+* **models.E050**: The model cannot have related fields with both
+ database-level and Python-level ``on_delete`` variants.
Management Commands
-------------------
@@ -921,6 +929,8 @@ The following checks are performed when a model contains a
* **contenttypes.E004**: ``<field>`` is not a ``ForeignKey`` to
``contenttypes.ContentType``.
* **contenttypes.E005**: Model names must be at most 100 characters.
+* **contenttypes.E006**: ``<field>`` cannot use the database-level
+ ``on_delete`` variant.
``postgres``
------------
diff --git a/docs/ref/models/fields.txt b/docs/ref/models/fields.txt
index 5988d5dc06..0b62143cc0 100644
--- a/docs/ref/models/fields.txt
+++ b/docs/ref/models/fields.txt
@@ -1699,11 +1699,11 @@ relation works.
.. attribute:: ForeignKey.on_delete
- When an object referenced by a :class:`ForeignKey` is deleted, Django will
- emulate the behavior of the SQL constraint specified by the
- :attr:`on_delete` argument. For example, if you have a nullable
- :class:`ForeignKey` and you want it to be set null when the referenced
- object is deleted::
+ When an object referenced by a :class:`ForeignKey` is deleted, the
+ referring objects need updating. The :attr:`on_delete` argument specifies
+ how this is done, and whether Django or your database makes the updates.
+ For example, if you have a nullable :class:`ForeignKey` and you want Django
+ to set it to ``None`` when the referenced object is deleted::
user = models.ForeignKey(
User,
@@ -1712,8 +1712,21 @@ relation works.
null=True,
)
- ``on_delete`` doesn't create an SQL constraint in the database. Support for
- database-level cascade options :ticket:`may be implemented later <21961>`.
+ The possible values for :attr:`~ForeignKey.on_delete` are listed below.
+ Import them from :mod:`django.db.models`. The ``DB_*`` variants use the
+ database to prevent deletions or update referring objects, whilst the other
+ values make Django perform the relevant actions.
+
+ The database variants are more efficient because they avoid fetching
+ related objects, but ``pre_delete`` and ``post_delete`` signals won't be
+ sent when ``DB_CASCADE`` is used.
+
+ The database variants cannot be mixed with Python variants (other than
+ :attr:`DO_NOTHING`) in the same model and in models related to each other.
+
+.. versionchanged:: 6.1
+
+ Support for ``DB_*`` variants of the ``on_delete`` attribute was added.
The possible values for :attr:`~ForeignKey.on_delete` are found in
:mod:`django.db.models`:
@@ -1729,6 +1742,13 @@ The possible values for :attr:`~ForeignKey.on_delete` are found in
:data:`~django.db.models.signals.post_delete` signals are sent for all
deleted objects.
+* .. attribute:: DB_CASCADE
+
+ .. versionadded:: 6.1
+
+ Cascade deletes. Database-level version of :attr:`CASCADE`: the database
+ deletes referred-to rows and the one containing the ``ForeignKey``.
+
* .. attribute:: PROTECT
Prevent deletion of the referenced object by raising
@@ -1782,11 +1802,30 @@ The possible values for :attr:`~ForeignKey.on_delete` are found in
Set the :class:`ForeignKey` null; this is only possible if
:attr:`~Field.null` is ``True``.
+* .. attribute:: DB_SET_NULL
+
+ .. versionadded:: 6.1
+
+ Set the :class:`ForeignKey` value to ``NULL``. This is only possible if
+ :attr:`~Field.null` is ``True``. Database-level version of
+ :attr:`SET_NULL`.
+
* .. attribute:: SET_DEFAULT
Set the :class:`ForeignKey` to its default value; a default for the
:class:`ForeignKey` must be set.
+* .. attribute:: DB_SET_DEFAULT
+
+ .. versionadded:: 6.1
+
+ Set the :class:`ForeignKey` value to its :attr:`Field.db_default` value,
+ which must be set. If a row in the referenced table is deleted, the foreign
+ key values in the referencing table will be updated to their
+ :attr:`Field.db_default` values.
+
+ ``DB_SET_DEFAULT`` is not supported on MySQL and MariaDB.
+
* .. function:: SET()
Set the :class:`ForeignKey` to the value passed to
diff --git a/docs/ref/models/instances.txt b/docs/ref/models/instances.txt
index 2ce8dc4a36..a8be767aaf 100644
--- a/docs/ref/models/instances.txt
+++ b/docs/ref/models/instances.txt
@@ -695,7 +695,11 @@ Issues an SQL ``DELETE`` for the object. This only deletes the object in the
database; the Python instance will still exist and will still have data in
its fields, except for the primary key set to ``None``. This method returns the
number of objects deleted and a dictionary with the number of deletions per
-object type.
+object type. The return value will count instances from related models if
+Django is emulating cascade behavior via Python :attr:`~ForeignKey.on_delete`
+variants. Otherwise, for database variants such as
+:attr:`~django.db.models.DB_CASCADE`, the return value will report only
+instances of the :class:`.QuerySet`'s model.
For more details, including how to delete objects in bulk, see
:ref:`topics-db-queries-delete`.
@@ -707,6 +711,10 @@ Sometimes with :ref:`multi-table inheritance <multi-table-inheritance>` you may
want to delete only a child model's data. Specifying ``keep_parents=True`` will
keep the parent model's data.
+.. versionchanged:: 6.1
+
+ Support for the ``DB_*`` variants of ``on_delete`` attribute was added.
+
Pickling objects
================
diff --git a/docs/ref/models/querysets.txt b/docs/ref/models/querysets.txt
index 3840a2f97e..164bc9ce54 100644
--- a/docs/ref/models/querysets.txt
+++ b/docs/ref/models/querysets.txt
@@ -3036,7 +3036,11 @@ unique field in the order that is specified without conflicts. For example::
Performs an SQL delete query on all rows in the :class:`.QuerySet` and
returns the number of objects deleted and a dictionary with the number of
-deletions per object type.
+deletions per object type. The return value will count instances from related
+models if Django is emulating cascade behavior via Python
+:attr:`~django.db.models.ForeignKey.on_delete` variants. Otherwise, for
+database variants such as :attr:`~django.db.models.DB_CASCADE`, the return
+value will report only instances of the :class:`.QuerySet`'s model.
The ``delete()`` is applied instantly. You cannot call ``delete()`` on a
:class:`.QuerySet` that has had a slice taken or can otherwise no longer be
@@ -3073,13 +3077,20 @@ The ``delete()`` method does a bulk delete and does not call any ``delete()``
methods on your models. It does, however, emit the
:data:`~django.db.models.signals.pre_delete` and
:data:`~django.db.models.signals.post_delete` signals for all deleted objects
-(including cascaded deletions).
+(including cascaded deletions). Signals won't be sent when ``DB_CASCADE`` is
+used. Also, ``delete()`` doesn't return information about objects deleted from
+database variants (``DB_*``) of the
+:attr:`~django.db.models.ForeignKey.on_delete` argument, e.g. ``DB_CASCADE``.
-Django needs to fetch objects into memory to send signals and handle cascades.
-However, if there are no cascades and no signals, then Django may take a
-fast-path and delete objects without fetching into memory. For large
-deletes this can result in significantly reduced memory usage. The amount of
-executed queries can be reduced, too.
+Django won’t need to fetch objects into memory when deleting them in the
+following cases:
+
+#. If related fields use ``DB_*`` options.
+#. If there are no cascades and no delete signal receivers.
+
+In these cases, Django may take a fast path and delete objects without fetching
+them, which can result in significantly reduced memory usage and fewer executed
+queries.
ForeignKeys which are set to :attr:`~django.db.models.ForeignKey.on_delete`
``DO_NOTHING`` do not prevent taking the fast-path in deletion.
@@ -3087,6 +3098,10 @@ ForeignKeys which are set to :attr:`~django.db.models.ForeignKey.on_delete`
Note that the queries generated in object deletion is an implementation
detail subject to change.
+.. versionchanged:: 6.1
+
+ Support for the ``DB_*`` variants of ``on_delete`` attribute was added.
+
``as_manager()``
~~~~~~~~~~~~~~~~
diff --git a/docs/releases/6.1.txt b/docs/releases/6.1.txt
index 80470dbcd6..d199423176 100644
--- a/docs/releases/6.1.txt
+++ b/docs/releases/6.1.txt
@@ -71,6 +71,21 @@ queries:
See :doc:`fetch modes </topics/db/fetch-modes>` for more details.
+Database-level delete options for ``ForeignKey.on_delete``
+----------------------------------------------------------
+
+:attr:`.ForeignKey.on_delete` now supports database-level delete options:
+
+* :attr:`~django.db.models.DB_CASCADE`
+* :attr:`~django.db.models.DB_SET_NULL`
+* :attr:`~django.db.models.DB_SET_DEFAULT`
+
+These options handle deletion logic entirely within the database, using the SQL
+``ON DELETE`` clause. They are thus more efficient than the existing
+Python-level options, as Django does not need to load objects before deleting
+them. As a consequence, the :attr:`~django.db.models.DB_CASCADE` option does
+not trigger the ``pre_delete`` or ``post_delete`` signals.
+
Minor features
--------------
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],