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 Sandford Nick Sarbicki + Nick Stefan Niclas Olofsson Nicola Larosa Nicolas Lara 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(), @@ -2455,6 +2456,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): 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`` ```` doesn't exist on the related 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=``, 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**: ```` does not support ``DB_SET_DEFAULT``. * **fields.E330**: ``ManyToManyField``\s cannot be unique. * **fields.E331**: Field specifies a many-to-many relation through model ````, which has not been installed. @@ -446,6 +452,8 @@ Models * **models.E049**: ``constraints/indexes/unique_together`` refers to a ``ForeignObject`` ```` 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**: ```` is not a ``ForeignKey`` to ``contenttypes.ContentType``. * **contenttypes.E005**: Model names must be at most 100 characters. +* **contenttypes.E006**: ```` 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 ` 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 ` 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, @@ -410,6 +412,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: @@ -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],