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:
1
AUTHORS
1
AUTHORS
@@ -791,6 +791,7 @@ answer newbie questions, and generally made Django that much better:
|
||||
Nick Presta <nick@nickpresta.ca>
|
||||
Nick Sandford <nick.sandford@gmail.com>
|
||||
Nick Sarbicki <nick.a.sarbicki@gmail.com>
|
||||
Nick Stefan <https://github.com/nickstefan>
|
||||
Niclas Olofsson <n@niclasolofsson.se>
|
||||
Nicola Larosa <nico@teknico.net>
|
||||
Nicolas Lara <nicolaslara@gmail.com>
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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 []
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,7 +342,12 @@ class Collector:
|
||||
continue
|
||||
field = related.field
|
||||
on_delete = field.remote_field.on_delete
|
||||
if on_delete == DO_NOTHING:
|
||||
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):
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -299,9 +299,15 @@ Related fields
|
||||
referenced by a ``ForeignKey``.
|
||||
* **fields.E312**: The ``to_field`` ``<field name>`` doesn't exist on the
|
||||
related model ``<app label>.<model>``.
|
||||
* **fields.E320**: Field specifies ``on_delete=SET_NULL``, but cannot be null.
|
||||
* **fields.E321**: The field specifies ``on_delete=SET_DEFAULT``, but has no
|
||||
default value.
|
||||
* **fields.E320**: Field specifies ``on_delete=<set null option>``, but cannot
|
||||
be null.
|
||||
* **fields.E321**: Field specifies ``on_delete=SET_DEFAULT``, but has no
|
||||
``default`` value.
|
||||
* **fields.E322**: Field specifies ``on_delete=DB_SET_DEFAULT``, but has no
|
||||
``db_default`` value.
|
||||
* **fields.E323**: Field specifies database/Python-level on_delete variant, but
|
||||
referenced model uses python/database-level variant.
|
||||
* **fields.E324**: ``<database>`` does not support ``DB_SET_DEFAULT``.
|
||||
* **fields.E330**: ``ManyToManyField``\s cannot be unique.
|
||||
* **fields.E331**: Field specifies a many-to-many relation through model
|
||||
``<model>``, which has not been installed.
|
||||
@@ -446,6 +452,8 @@ Models
|
||||
* **models.E049**: ``constraints/indexes/unique_together`` refers to a
|
||||
``ForeignObject`` ``<field name>`` with multiple ``from_fields``, which is
|
||||
not supported for that option.
|
||||
* **models.E050**: The model cannot have related fields with both
|
||||
database-level and Python-level ``on_delete`` variants.
|
||||
|
||||
Management Commands
|
||||
-------------------
|
||||
@@ -921,6 +929,8 @@ The following checks are performed when a model contains a
|
||||
* **contenttypes.E004**: ``<field>`` is not a ``ForeignKey`` to
|
||||
``contenttypes.ContentType``.
|
||||
* **contenttypes.E005**: Model names must be at most 100 characters.
|
||||
* **contenttypes.E006**: ``<field>`` cannot use the database-level
|
||||
``on_delete`` variant.
|
||||
|
||||
``postgres``
|
||||
------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -695,7 +695,11 @@ Issues an SQL ``DELETE`` for the object. This only deletes the object in the
|
||||
database; the Python instance will still exist and will still have data in
|
||||
its fields, except for the primary key set to ``None``. This method returns the
|
||||
number of objects deleted and a dictionary with the number of deletions per
|
||||
object type.
|
||||
object type. The return value will count instances from related models if
|
||||
Django is emulating cascade behavior via Python :attr:`~ForeignKey.on_delete`
|
||||
variants. Otherwise, for database variants such as
|
||||
:attr:`~django.db.models.DB_CASCADE`, the return value will report only
|
||||
instances of the :class:`.QuerySet`'s model.
|
||||
|
||||
For more details, including how to delete objects in bulk, see
|
||||
:ref:`topics-db-queries-delete`.
|
||||
@@ -707,6 +711,10 @@ Sometimes with :ref:`multi-table inheritance <multi-table-inheritance>` you may
|
||||
want to delete only a child model's data. Specifying ``keep_parents=True`` will
|
||||
keep the parent model's data.
|
||||
|
||||
.. versionchanged:: 6.1
|
||||
|
||||
Support for the ``DB_*`` variants of ``on_delete`` attribute was added.
|
||||
|
||||
Pickling objects
|
||||
================
|
||||
|
||||
|
||||
@@ -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()``
|
||||
~~~~~~~~~~~~~~~~
|
||||
|
||||
|
||||
@@ -71,6 +71,21 @@ queries:
|
||||
|
||||
See :doc:`fetch modes </topics/db/fetch-modes>` for more details.
|
||||
|
||||
Database-level delete options for ``ForeignKey.on_delete``
|
||||
----------------------------------------------------------
|
||||
|
||||
:attr:`.ForeignKey.on_delete` now supports database-level delete options:
|
||||
|
||||
* :attr:`~django.db.models.DB_CASCADE`
|
||||
* :attr:`~django.db.models.DB_SET_NULL`
|
||||
* :attr:`~django.db.models.DB_SET_DEFAULT`
|
||||
|
||||
These options handle deletion logic entirely within the database, using the SQL
|
||||
``ON DELETE`` clause. They are thus more efficient than the existing
|
||||
Python-level options, as Django does not need to load objects before deleting
|
||||
them. As a consequence, the :attr:`~django.db.models.DB_CASCADE` option does
|
||||
not trigger the ``pre_delete`` or ``post_delete`` signals.
|
||||
|
||||
Minor features
|
||||
--------------
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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-"
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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), [])
|
||||
|
||||
@@ -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), [])
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user