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