1
0
mirror of https://github.com/django/django.git synced 2025-10-26 15:16:09 +00:00

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>
This commit is contained in:
Mariusz Felisiak
2025-10-18 15:03:50 +02:00
committed by GitHub
parent b1e0262c9f
commit 0c487aa3a7
33 changed files with 838 additions and 67 deletions

View File

@@ -791,6 +791,7 @@ answer newbie questions, and generally made Django that much better:
Nick Presta <nick@nickpresta.ca> Nick Presta <nick@nickpresta.ca>
Nick Sandford <nick.sandford@gmail.com> Nick Sandford <nick.sandford@gmail.com>
Nick Sarbicki <nick.a.sarbicki@gmail.com> Nick Sarbicki <nick.a.sarbicki@gmail.com>
Nick Stefan <https://github.com/nickstefan>
Niclas Olofsson <n@niclasolofsson.se> Niclas Olofsson <n@niclasolofsson.se>
Nicola Larosa <nico@teknico.net> Nicola Larosa <nico@teknico.net>
Nicolas Lara <nicolaslara@gmail.com> Nicolas Lara <nicolaslara@gmail.com>

View File

@@ -184,8 +184,8 @@ def get_deleted_objects(objs, request, admin_site):
class NestedObjects(Collector): class NestedObjects(Collector):
def __init__(self, *args, **kwargs): def __init__(self, *args, force_collection=True, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, force_collection=force_collection, **kwargs)
self.edges = {} # {from_instance: [to_instances]} self.edges = {} # {from_instance: [to_instances]}
self.protected = set() self.protected = set()
self.model_objs = defaultdict(set) self.model_objs = defaultdict(set)
@@ -242,13 +242,6 @@ class NestedObjects(Collector):
roots.extend(self._nested(root, seen, format_callback)) roots.extend(self._nested(root, seen, format_callback))
return roots 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): def model_format_dict(obj):
""" """

View File

@@ -10,6 +10,7 @@ from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist
from django.db import DEFAULT_DB_ALIAS, models, router, transaction from django.db import DEFAULT_DB_ALIAS, models, router, transaction
from django.db.models import DO_NOTHING, ForeignObject, ForeignObjectRel from django.db.models import DO_NOTHING, ForeignObject, ForeignObjectRel
from django.db.models.base import ModelBase, make_foreign_order_accessors 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 import Field
from django.db.models.fields.mixins import FieldCacheMixin from django.db.models.fields.mixins import FieldCacheMixin
from django.db.models.fields.related import ( from django.db.models.fields.related import (
@@ -139,6 +140,16 @@ class GenericForeignKey(FieldCacheMixin, Field):
id="contenttypes.E004", 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: else:
return [] return []

View File

@@ -61,7 +61,9 @@ class Command(BaseCommand):
ct_info.append( ct_info.append(
" - Content type for %s.%s" % (ct.app_label, ct.model) " - 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]) collector.collect([ct])
for obj_type, objs in collector.data.items(): for obj_type, objs in collector.data.items():
@@ -103,11 +105,3 @@ class Command(BaseCommand):
else: else:
if verbosity >= 2: if verbosity >= 2:
self.stdout.write("Stale content types remain.") 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

View File

@@ -390,6 +390,9 @@ class BaseDatabaseFeatures:
# subqueries? # subqueries?
supports_tuple_comparison_against_subquery = True 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. # Collation names for use by the Django test suite.
test_collations = { test_collations = {
"ci": None, # Case-insensitive. "ci": None, # Case-insensitive.

View File

@@ -254,6 +254,16 @@ class BaseDatabaseOperations:
if sql 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): def bulk_insert_sql(self, fields, placeholder_rows):
placeholder_rows_sql = (", ".join(row) for row in placeholder_rows) placeholder_rows_sql = (", ".join(row) for row in placeholder_rows)
values_sql = ", ".join([f"({sql})" for sql in placeholder_rows_sql]) values_sql = ", ".join([f"({sql})" for sql in placeholder_rows_sql])

View File

@@ -121,7 +121,7 @@ class BaseDatabaseSchemaEditor:
sql_create_fk = ( sql_create_fk = (
"ALTER TABLE %(table)s ADD CONSTRAINT %(name)s FOREIGN KEY (%(column)s) " "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_inline_fk = None
sql_create_column_inline_fk = None sql_create_column_inline_fk = None
@@ -241,6 +241,7 @@ class BaseDatabaseSchemaEditor:
definition += " " + self.sql_create_inline_fk % { definition += " " + self.sql_create_inline_fk % {
"to_table": self.quote_name(to_table), "to_table": self.quote_name(to_table),
"to_column": self.quote_name(to_column), "to_column": self.quote_name(to_column),
"on_delete_db": self._create_on_delete_sql(model, field),
} }
elif self.connection.features.supports_foreign_keys: elif self.connection.features.supports_foreign_keys:
self.deferred_sql.append( self.deferred_sql.append(
@@ -759,6 +760,7 @@ class BaseDatabaseSchemaEditor:
"to_table": self.quote_name(to_table), "to_table": self.quote_name(to_table),
"to_column": self.quote_name(to_column), "to_column": self.quote_name(to_column),
"deferrable": self.connection.ops.deferrable_sql(), "deferrable": self.connection.ops.deferrable_sql(),
"on_delete_db": self._create_on_delete_sql(model, field),
} }
# Otherwise, add FK constraints later. # Otherwise, add FK constraints later.
else: else:
@@ -1628,6 +1630,13 @@ class BaseDatabaseSchemaEditor:
new_name=self.quote_name(new_name), 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): def _index_columns(self, table, columns, col_suffixes, opclasses):
return Columns(table, columns, self.quote_name, col_suffixes=col_suffixes) return Columns(table, columns, self.quote_name, col_suffixes=col_suffixes)
@@ -1740,6 +1749,7 @@ class BaseDatabaseSchemaEditor:
to_table=to_table, to_table=to_table,
to_column=to_column, to_column=to_column,
deferrable=deferrable, deferrable=deferrable,
on_delete_db=self._create_on_delete_sql(model, field),
) )
def _fk_constraint_name(self, model, field, suffix): def _fk_constraint_name(self, model, field, suffix):

View File

@@ -44,6 +44,7 @@ class DatabaseFeatures(BaseDatabaseFeatures):
SET V_I = P_I; SET V_I = P_I;
END; END;
""" """
supports_on_delete_db_default = False
# Neither MySQL nor MariaDB support partial indexes. # Neither MySQL nor MariaDB support partial indexes.
supports_partial_indexes = False supports_partial_indexes = False
# COLLATE must be wrapped in parentheses because MySQL treats COLLATE as an # COLLATE must be wrapped in parentheses because MySQL treats COLLATE as an

View File

@@ -14,7 +14,7 @@ class DatabaseSchemaEditor(BaseDatabaseSchemaEditor):
sql_delete_unique = "ALTER TABLE %(table)s DROP INDEX %(name)s" sql_delete_unique = "ALTER TABLE %(table)s DROP INDEX %(name)s"
sql_create_column_inline_fk = ( sql_create_column_inline_fk = (
", ADD CONSTRAINT %(name)s FOREIGN KEY (%(column)s) " ", 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" sql_delete_fk = "ALTER TABLE %(table)s DROP FOREIGN KEY %(name)s"

View File

@@ -78,6 +78,7 @@ class DatabaseFeatures(BaseDatabaseFeatures):
supports_json_field_contains = False supports_json_field_contains = False
supports_json_negative_indexing = False supports_json_negative_indexing = False
supports_collation_on_textfield = False supports_collation_on_textfield = False
supports_on_delete_db_default = False
test_now_utc_template = "CURRENT_TIMESTAMP AT TIME ZONE 'UTC'" test_now_utc_template = "CURRENT_TIMESTAMP AT TIME ZONE 'UTC'"
django_test_expected_failures = { django_test_expected_failures = {
# A bug in Django/oracledb with respect to string handling (#23843). # A bug in Django/oracledb with respect to string handling (#23843).

View File

@@ -20,7 +20,8 @@ class DatabaseSchemaEditor(BaseDatabaseSchemaEditor):
sql_alter_column_no_default_null = sql_alter_column_no_default sql_alter_column_no_default_null = sql_alter_column_no_default
sql_create_column_inline_fk = ( 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_delete_table = "DROP TABLE %(table)s CASCADE CONSTRAINTS"
sql_create_index = "CREATE INDEX %(name)s ON %(table)s (%(columns)s)%(extra)s" sql_create_index = "CREATE INDEX %(name)s ON %(table)s (%(columns)s)%(extra)s"

View File

@@ -28,8 +28,8 @@ class DatabaseSchemaEditor(BaseDatabaseSchemaEditor):
# Setting the constraint to IMMEDIATE to allow changing data in the same # Setting the constraint to IMMEDIATE to allow changing data in the same
# transaction. # transaction.
sql_create_column_inline_fk = ( 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"
"; SET CONSTRAINTS %(namespace)s%(name)s IMMEDIATE" "%(deferrable)s; SET CONSTRAINTS %(namespace)s%(name)s IMMEDIATE"
) )
# Setting the constraint to IMMEDIATE runs any deferred checks to allow # Setting the constraint to IMMEDIATE runs any deferred checks to allow
# dropping it in the same transaction. # dropping it in the same transaction.

View File

@@ -13,7 +13,8 @@ class DatabaseSchemaEditor(BaseDatabaseSchemaEditor):
sql_delete_table = "DROP TABLE %(table)s" sql_delete_table = "DROP TABLE %(table)s"
sql_create_fk = None sql_create_fk = None
sql_create_inline_fk = ( 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_column_inline_fk = sql_create_inline_fk
sql_create_unique = "CREATE UNIQUE INDEX %(name)s ON %(table)s (%(columns)s)" sql_create_unique = "CREATE UNIQUE INDEX %(name)s ON %(table)s (%(columns)s)"

View File

@@ -16,6 +16,7 @@ from django.conf import SettingsReference
from django.db import models from django.db import models
from django.db.migrations.operations.base import Operation from django.db.migrations.operations.base import Operation
from django.db.migrations.utils import COMPILED_REGEX_TYPE, RegexObject 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.functional import LazyObject, Promise
from django.utils.version import get_docs_version from django.utils.version import get_docs_version
@@ -71,6 +72,12 @@ class ChoicesSerializer(BaseSerializer):
return serializer_factory(self.value.value).serialize() 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): class DateTimeSerializer(BaseSerializer):
"""For datetime.*, except datetime.datetime.""" """For datetime.*, except datetime.datetime."""
@@ -363,6 +370,7 @@ class Serializer:
pathlib.PurePath: PathSerializer, pathlib.PurePath: PathSerializer,
os.PathLike: PathLikeSerializer, os.PathLike: PathLikeSerializer,
zoneinfo.ZoneInfo: ZoneInfoSerializer, zoneinfo.ZoneInfo: ZoneInfoSerializer,
DatabaseOnDelete: DatabaseOnDeleteSerializer,
} }
@classmethod @classmethod

View File

@@ -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.constraints import __all__ as constraints_all
from django.db.models.deletion import ( from django.db.models.deletion import (
CASCADE, CASCADE,
DB_CASCADE,
DB_SET_DEFAULT,
DB_SET_NULL,
DO_NOTHING, DO_NOTHING,
PROTECT, PROTECT,
RESTRICT, RESTRICT,
@@ -75,6 +78,9 @@ __all__ += [
"ObjectDoesNotExist", "ObjectDoesNotExist",
"signals", "signals",
"CASCADE", "CASCADE",
"DB_CASCADE",
"DB_SET_DEFAULT",
"DB_SET_NULL",
"DO_NOTHING", "DO_NOTHING",
"PROTECT", "PROTECT",
"RESTRICT", "RESTRICT",

View File

@@ -30,7 +30,7 @@ from django.db import (
) )
from django.db.models import NOT_PROVIDED, ExpressionWrapper, IntegerField, Max, Value from django.db.models import NOT_PROVIDED, ExpressionWrapper, IntegerField, Max, Value
from django.db.models.constants import LOOKUP_SEP 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.expressions import DatabaseDefault
from django.db.models.fetch_modes import FETCH_ONE from django.db.models.fetch_modes import FETCH_ONE
from django.db.models.fields.composite import CompositePrimaryKey from django.db.models.fields.composite import CompositePrimaryKey
@@ -1770,6 +1770,7 @@ class Model(AltersData, metaclass=ModelBase):
*cls._check_fields(**kwargs), *cls._check_fields(**kwargs),
*cls._check_m2m_through_same_relationship(), *cls._check_m2m_through_same_relationship(),
*cls._check_long_column_names(databases), *cls._check_long_column_names(databases),
*cls._check_related_fields(),
] ]
clash_errors = ( clash_errors = (
*cls._check_id_field(), *cls._check_id_field(),
@@ -2455,6 +2456,29 @@ class Model(AltersData, metaclass=ModelBase):
return errors 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 @classmethod
def _get_expr_references(cls, expr): def _get_expr_references(cls, expr):
if isinstance(expr, Q): if isinstance(expr, Q):

View File

@@ -81,6 +81,28 @@ def DO_NOTHING(collector, field, sub_objs, using):
pass 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): def get_candidate_relations_to_delete(opts):
# The candidate relations are the ones that come from N-1 and 1-1 # 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 # 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: class Collector:
def __init__(self, using, origin=None): def __init__(self, using, origin=None, force_collection=False):
self.using = using self.using = using
# A Model or QuerySet object. # A Model or QuerySet object.
self.origin = origin self.origin = origin
# Force collecting objects for deletion on the Python-level.
self.force_collection = force_collection
# Initially, {model: {instances}}, later values become lists. # Initially, {model: {instances}}, later values become lists.
self.data = defaultdict(set) self.data = defaultdict(set)
# {(field, value): [instances, …]} # {(field, value): [instances, …]}
@@ -194,6 +218,8 @@ class Collector:
skipping parent -> child -> parent chain preventing fast delete of skipping parent -> child -> parent chain preventing fast delete of
the child. the child.
""" """
if self.force_collection:
return False
if from_field and from_field.remote_field.on_delete is not CASCADE: if from_field and from_field.remote_field.on_delete is not CASCADE:
return False return False
if hasattr(objs, "_meta"): if hasattr(objs, "_meta"):
@@ -215,7 +241,7 @@ class Collector:
and and
# Foreign keys pointing to this model. # Foreign keys pointing to this model.
all( 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) for related in get_candidate_relations_to_delete(opts)
) )
and ( and (
@@ -316,8 +342,13 @@ class Collector:
continue continue
field = related.field field = related.field
on_delete = field.remote_field.on_delete on_delete = field.remote_field.on_delete
if on_delete == DO_NOTHING: if on_delete in SKIP_COLLECTION:
continue 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 related_model = related.related_model
if self.can_fast_delete(related_model, from_field=field): if self.can_fast_delete(related_model, from_field=field):
model_fast_deletes[related_model].append(field) model_fast_deletes[related_model].append(field)

View File

@@ -155,8 +155,6 @@ class Field(RegisterLookupMixin):
"error_messages", "error_messages",
"help_text", "help_text",
"limit_choices_to", "limit_choices_to",
# Database-level options are not supported, see #21961.
"on_delete",
"related_name", "related_name",
"related_query_name", "related_query_name",
"validators", "validators",

View File

@@ -6,11 +6,19 @@ from django import forms
from django.apps import apps from django.apps import apps
from django.conf import SettingsReference, settings from django.conf import SettingsReference, settings
from django.core import checks, exceptions 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.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.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.query_utils import PathInfo
from django.db.models.utils import make_model_tuple from django.db.models.utils import make_model_tuple
from django.utils.functional import cached_property from django.utils.functional import cached_property
@@ -1041,18 +1049,21 @@ class ForeignKey(ForeignObject):
return cls return cls
def check(self, **kwargs): def check(self, **kwargs):
databases = kwargs.get("databases") or []
return [ return [
*super().check(**kwargs), *super().check(**kwargs),
*self._check_on_delete(), *self._check_on_delete(databases),
*self._check_unique(), *self._check_unique(),
] ]
def _check_on_delete(self): def _check_on_delete(self, databases):
on_delete = getattr(self.remote_field, "on_delete", None) on_delete = getattr(self.remote_field, "on_delete", None)
if on_delete == SET_NULL and not self.null: errors = []
return [ if on_delete in [DB_SET_NULL, SET_NULL] and not self.null:
errors.append(
checks.Error( 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=( hint=(
"Set null=True argument on the field, or change the on_delete " "Set null=True argument on the field, or change the on_delete "
"rule." "rule."
@@ -1060,18 +1071,80 @@ class ForeignKey(ForeignObject):
obj=self, obj=self,
id="fields.E320", id="fields.E320",
) )
] )
elif on_delete == SET_DEFAULT and not self.has_default(): elif on_delete == SET_DEFAULT and not self.has_default():
return [ errors.append(
checks.Error( checks.Error(
"Field specifies on_delete=SET_DEFAULT, but has no default value.", "Field specifies on_delete=SET_DEFAULT, but has no default value.",
hint="Set a default value, or change the on_delete rule.", hint="Set a default value, or change the on_delete rule.",
obj=self, obj=self,
id="fields.E321", id="fields.E321",
) )
] )
else: elif on_delete == DB_SET_DEFAULT:
return [] 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): def _check_unique(self, **kwargs):
return ( return (

View File

@@ -299,9 +299,15 @@ Related fields
referenced by a ``ForeignKey``. referenced by a ``ForeignKey``.
* **fields.E312**: The ``to_field`` ``<field name>`` doesn't exist on the * **fields.E312**: The ``to_field`` ``<field name>`` doesn't exist on the
related model ``<app label>.<model>``. related model ``<app label>.<model>``.
* **fields.E320**: Field specifies ``on_delete=SET_NULL``, but cannot be null. * **fields.E320**: Field specifies ``on_delete=<set null option>``, but cannot
* **fields.E321**: The field specifies ``on_delete=SET_DEFAULT``, but has no be null.
default value. * **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.E330**: ``ManyToManyField``\s cannot be unique.
* **fields.E331**: Field specifies a many-to-many relation through model * **fields.E331**: Field specifies a many-to-many relation through model
``<model>``, which has not been installed. ``<model>``, which has not been installed.
@@ -446,6 +452,8 @@ Models
* **models.E049**: ``constraints/indexes/unique_together`` refers to a * **models.E049**: ``constraints/indexes/unique_together`` refers to a
``ForeignObject`` ``<field name>`` with multiple ``from_fields``, which is ``ForeignObject`` ``<field name>`` with multiple ``from_fields``, which is
not supported for that option. 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 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.E004**: ``<field>`` is not a ``ForeignKey`` to
``contenttypes.ContentType``. ``contenttypes.ContentType``.
* **contenttypes.E005**: Model names must be at most 100 characters. * **contenttypes.E005**: Model names must be at most 100 characters.
* **contenttypes.E006**: ``<field>`` cannot use the database-level
``on_delete`` variant.
``postgres`` ``postgres``
------------ ------------

View File

@@ -1699,11 +1699,11 @@ relation works.
.. attribute:: ForeignKey.on_delete .. attribute:: ForeignKey.on_delete
When an object referenced by a :class:`ForeignKey` is deleted, Django will When an object referenced by a :class:`ForeignKey` is deleted, the
emulate the behavior of the SQL constraint specified by the referring objects need updating. The :attr:`on_delete` argument specifies
:attr:`on_delete` argument. For example, if you have a nullable how this is done, and whether Django or your database makes the updates.
:class:`ForeignKey` and you want it to be set null when the referenced For example, if you have a nullable :class:`ForeignKey` and you want Django
object is deleted:: to set it to ``None`` when the referenced object is deleted::
user = models.ForeignKey( user = models.ForeignKey(
User, User,
@@ -1712,8 +1712,21 @@ relation works.
null=True, null=True,
) )
``on_delete`` doesn't create an SQL constraint in the database. Support for The possible values for :attr:`~ForeignKey.on_delete` are listed below.
database-level cascade options :ticket:`may be implemented later <21961>`. 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 The possible values for :attr:`~ForeignKey.on_delete` are found in
:mod:`django.db.models`: :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 :data:`~django.db.models.signals.post_delete` signals are sent for all
deleted objects. 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 * .. attribute:: PROTECT
Prevent deletion of the referenced object by raising 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 Set the :class:`ForeignKey` null; this is only possible if
:attr:`~Field.null` is ``True``. :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 * .. attribute:: SET_DEFAULT
Set the :class:`ForeignKey` to its default value; a default for the Set the :class:`ForeignKey` to its default value; a default for the
:class:`ForeignKey` must be set. :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() * .. function:: SET()
Set the :class:`ForeignKey` to the value passed to Set the :class:`ForeignKey` to the value passed to

View File

@@ -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 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 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 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 For more details, including how to delete objects in bulk, see
:ref:`topics-db-queries-delete`. :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 want to delete only a child model's data. Specifying ``keep_parents=True`` will
keep the parent model's data. keep the parent model's data.
.. versionchanged:: 6.1
Support for the ``DB_*`` variants of ``on_delete`` attribute was added.
Pickling objects Pickling objects
================ ================

View File

@@ -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 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 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 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 :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 methods on your models. It does, however, emit the
:data:`~django.db.models.signals.pre_delete` and :data:`~django.db.models.signals.pre_delete` and
:data:`~django.db.models.signals.post_delete` signals for all deleted objects :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. Django wont need to fetch objects into memory when deleting them in the
However, if there are no cascades and no signals, then Django may take a following cases:
fast-path and delete objects without fetching into memory. For large
deletes this can result in significantly reduced memory usage. The amount of #. If related fields use ``DB_*`` options.
executed queries can be reduced, too. #. 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` ForeignKeys which are set to :attr:`~django.db.models.ForeignKey.on_delete`
``DO_NOTHING`` do not prevent taking the fast-path in deletion. ``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 Note that the queries generated in object deletion is an implementation
detail subject to change. detail subject to change.
.. versionchanged:: 6.1
Support for the ``DB_*`` variants of ``on_delete`` attribute was added.
``as_manager()`` ``as_manager()``
~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~

View File

@@ -71,6 +71,21 @@ queries:
See :doc:`fetch modes </topics/db/fetch-modes>` for more details. 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 Minor features
-------------- --------------

View File

@@ -40,7 +40,7 @@ class ArticleProxy(Article):
proxy = True proxy = True
class Count(models.Model): class Cascade(models.Model):
num = models.PositiveSmallIntegerField() num = models.PositiveSmallIntegerField()
parent = models.ForeignKey("self", models.CASCADE, null=True) parent = models.ForeignKey("self", models.CASCADE, null=True)
@@ -48,6 +48,14 @@ class Count(models.Model):
return str(self.num) 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): class Event(models.Model):
date = models.DateTimeField(auto_now_add=True) date = models.DateTimeField(auto_now_add=True)

View File

@@ -26,7 +26,17 @@ from django.test.utils import isolate_apps
from django.utils.formats import localize from django.utils.formats import localize
from django.utils.safestring import mark_safe 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): class NestedObjectsTests(TestCase):
@@ -34,10 +44,12 @@ class NestedObjectsTests(TestCase):
Tests for ``NestedObject`` utility collection. Tests for ``NestedObject`` utility collection.
""" """
cascade_model = Cascade
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
cls.n = NestedObjects(using=DEFAULT_DB_ALIAS) 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): def _check(self, target):
self.assertEqual(self.n.nested(lambda obj: obj.num), target) self.assertEqual(self.n.nested(lambda obj: obj.num), target)
@@ -103,6 +115,15 @@ class NestedObjectsTests(TestCase):
n.collect([Vehicle.objects.first()]) 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): class UtilsTests(SimpleTestCase):
empty_value = "-empty-" empty_value = "-empty-"

View File

@@ -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): def test_missing_object_id_field(self):
class TaggedItem(models.Model): class TaggedItem(models.Model):
content_type = models.ForeignKey(ContentType, models.CASCADE) content_type = models.ForeignKey(ContentType, models.CASCADE)

View File

@@ -41,6 +41,46 @@ class RChildChild(RChild):
pass 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): class A(models.Model):
name = models.CharField(max_length=30) name = models.CharField(max_length=30)
@@ -119,6 +159,15 @@ def create_a(name):
return a 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): class M(models.Model):
m2m = models.ManyToManyField(R, related_name="m_set") m2m = models.ManyToManyField(R, related_name="m_set")
m2m_through = models.ManyToManyField(R, through="MR", related_name="m_through_set") m2m_through = models.ManyToManyField(R, through="MR", related_name="m_through_set")

View File

@@ -34,11 +34,16 @@ from .models import (
RChild, RChild,
RChildChild, RChildChild,
Referrer, Referrer,
RelatedDbOption,
RelatedDbOptionGrandParent,
RelatedDbOptionParent,
RProxy, RProxy,
S, S,
SetDefaultDbModel,
T, T,
User, User,
create_a, create_a,
create_related_db_option,
get_default_r, get_default_r,
) )
@@ -76,18 +81,48 @@ class OnDeleteTests(TestCase):
a = A.objects.get(pk=a.pk) a = A.objects.get(pk=a.pk)
self.assertIsNone(a.setnull) 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): def test_setdefault(self):
a = create_a("setdefault") a = create_a("setdefault")
a.setdefault.delete() a.setdefault.delete()
a = A.objects.get(pk=a.pk) a = A.objects.get(pk=a.pk)
self.assertEqual(self.DEFAULT, a.setdefault.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): def test_setdefault_none(self):
a = create_a("setdefault_none") a = create_a("setdefault_none")
a.setdefault_none.delete() a.setdefault_none.delete()
a = A.objects.get(pk=a.pk) a = A.objects.get(pk=a.pk)
self.assertIsNone(a.setdefault_none) 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): def test_cascade(self):
a = create_a("cascade") a = create_a("cascade")
a.cascade.delete() a.cascade.delete()
@@ -359,6 +394,22 @@ class DeletionTests(TestCase):
self.assertNumQueries(5, s.delete) self.assertNumQueries(5, s.delete)
self.assertFalse(S.objects.exists()) 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): def test_instance_update(self):
deleted = [] deleted = []
related_setnull_sets = [] related_setnull_sets = []

View File

@@ -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), [])

View File

@@ -3,7 +3,8 @@ from unittest import mock
from django.core.checks import Error from django.core.checks import Error
from django.core.checks import Warning as DjangoWarning from django.core.checks import Warning as DjangoWarning
from django.db import connection, models 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 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): def test_on_delete_set_default_without_default_value(self):
class Person(models.Model): class Person(models.Model):
pass 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), [])

View File

@@ -971,6 +971,23 @@ class WriterTests(SimpleTestCase):
("('models.Model', {'from django.db import models'})", set()), ("('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): def test_simple_migration(self):
""" """
Tests serializing a simple migration. Tests serializing a simple migration.

View File

@@ -18,6 +18,8 @@ from django.db import (
from django.db.backends.utils import truncate_name from django.db.backends.utils import truncate_name
from django.db.models import ( from django.db.models import (
CASCADE, CASCADE,
DB_CASCADE,
DB_SET_NULL,
PROTECT, PROTECT,
AutoField, AutoField,
BigAutoField, 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") @skipUnlessDBFeature("can_create_inline_fk")
def test_add_inline_fk_update_data(self): def test_add_inline_fk_update_data(self):
with connection.schema_editor() as editor: with connection.schema_editor() as editor:
@@ -566,6 +602,63 @@ class SchemaTests(TransactionTestCase):
editor.alter_field(Author, new_field2, new_field, strict=True) editor.alter_field(Author, new_field2, new_field, strict=True)
self.assertForeignKeyNotExists(Author, "tag_id", "schema_tag") 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") @isolate_apps("schema")
def test_no_db_constraint_added_during_primary_key_change(self): 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_table": editor.quote_name(table),
"to_column": editor.quote_name(model._meta.auto_field.column), "to_column": editor.quote_name(model._meta.auto_field.column),
"deferrable": connection.ops.deferrable_sql(), "deferrable": connection.ops.deferrable_sql(),
"on_delete_db": "",
} }
) )
self.assertIn( self.assertIn(
@@ -4784,7 +4878,7 @@ class SchemaTests(TransactionTestCase):
error_messages={"invalid": "error message"}, error_messages={"invalid": "error message"},
help_text="help text", help_text="help text",
limit_choices_to={"limit": "choice"}, limit_choices_to={"limit": "choice"},
on_delete=PROTECT, on_delete=CASCADE,
related_name="related_name", related_name="related_name",
related_query_name="related_query_name", related_query_name="related_query_name",
validators=[lambda x: x], validators=[lambda x: x],