Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
11 changes: 2 additions & 9 deletions django/contrib/admin/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,8 +184,8 @@ def format_callback(obj):


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)
Expand Down Expand Up @@ -242,13 +242,6 @@ def nested(self, format_callback=None):
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):
"""
Expand Down
11 changes: 11 additions & 0 deletions django/contrib/contenttypes/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
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 (
Expand Down Expand Up @@ -139,6 +140,16 @@ def _check_content_type_field(self):
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 []

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,9 @@ def handle(self, **options):
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():
Expand Down Expand Up @@ -103,11 +105,3 @@ def handle(self, **options):
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
3 changes: 3 additions & 0 deletions django/db/backends/base/features.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions django/db/backends/base/operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,16 @@ def limit_offset_sql(self, low_mark, high_mark):
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])
Expand Down
12 changes: 11 additions & 1 deletion django/db/backends/base/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -241,6 +241,7 @@ def table_sql(self, model):
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(
Expand Down Expand Up @@ -759,6 +760,7 @@ def add_field(self, model, field):
"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:
Expand Down Expand Up @@ -1628,6 +1630,13 @@ def _rename_index_sql(self, model, old_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):
return Columns(table, columns, self.quote_name, col_suffixes=col_suffixes)

Expand Down Expand Up @@ -1740,6 +1749,7 @@ def _create_fk_sql(self, model, field, suffix):
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):
Expand Down
1 change: 1 addition & 0 deletions django/db/backends/mysql/features.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion django/db/backends/mysql/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
1 change: 1 addition & 0 deletions django/db/backends/oracle/features.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
3 changes: 2 additions & 1 deletion django/db/backends/oracle/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions django/db/backends/postgresql/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion django/db/backends/sqlite3/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down
8 changes: 8 additions & 0 deletions django/db/migrations/serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
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

Expand Down Expand Up @@ -71,6 +72,12 @@ def serialize(self):
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."""

Expand Down Expand Up @@ -363,6 +370,7 @@ class Serializer:
pathlib.PurePath: PathSerializer,
os.PathLike: PathLikeSerializer,
zoneinfo.ZoneInfo: ZoneInfoSerializer,
DatabaseOnDelete: DatabaseOnDeleteSerializer,
}

@classmethod
Expand Down
6 changes: 6 additions & 0 deletions django/db/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
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,
Expand Down Expand Up @@ -75,6 +78,9 @@
"ObjectDoesNotExist",
"signals",
"CASCADE",
"DB_CASCADE",
"DB_SET_DEFAULT",
"DB_SET_NULL",
"DO_NOTHING",
"PROTECT",
"RESTRICT",
Expand Down
26 changes: 25 additions & 1 deletion django/db/models/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
)
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
Expand Down Expand Up @@ -1770,6 +1770,7 @@ def check(cls, **kwargs):
*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(),
Expand Down Expand Up @@ -2455,6 +2456,29 @@ def _check_long_column_names(cls, databases):

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):
Expand Down
39 changes: 35 additions & 4 deletions django/db/models/deletion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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, …]}
Expand Down Expand Up @@ -194,6 +218,8 @@ def can_fast_delete(self, objs, from_field=None):
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"):
Expand All @@ -215,7 +241,7 @@ def can_fast_delete(self, objs, from_field=None):
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 (
Expand Down Expand Up @@ -316,8 +342,13 @@ def collect(
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)
Expand Down
2 changes: 0 additions & 2 deletions django/db/models/fields/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading