From 670c510ac64212d189a1e48d1fa5888c69fecf1f Mon Sep 17 00:00:00 2001 From: Akash Kumar Sen Date: Thu, 11 May 2023 06:16:45 +0530 Subject: [PATCH 01/60] Modify the SQL query --- django/db/backends/base/schema.py | 10 ++++++- django/db/backends/sqlite3/schema.py | 3 ++- django/db/models/__init__.py | 2 ++ django/db/models/deletion.py | 17 ++++++++++++ django/db/models/fields/related.py | 31 ++++++++++++++++++++-- django/db/models/fields/reverse_related.py | 6 +++++ tests/db_cascade_foreignkey/__init__.py | 0 tests/db_cascade_foreignkey/models.py | 25 +++++++++++++++++ tests/db_cascade_foreignkey/tests.py | 27 +++++++++++++++++++ 9 files changed, 117 insertions(+), 4 deletions(-) create mode 100644 tests/db_cascade_foreignkey/__init__.py create mode 100644 tests/db_cascade_foreignkey/models.py create mode 100644 tests/db_cascade_foreignkey/tests.py diff --git a/django/db/backends/base/schema.py b/django/db/backends/base/schema.py index 6b03450e2f44..a926979a4336 100644 --- a/django/db/backends/base/schema.py +++ b/django/db/backends/base/schema.py @@ -117,7 +117,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 @@ -237,6 +237,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( @@ -1482,6 +1483,12 @@ 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): + on_delete_db = getattr(field.remote_field, "on_delete_db", None) + if on_delete_db.value: + return "ON DELETE %s" % on_delete_db.value + return "" + def _index_columns(self, table, columns, col_suffixes, opclasses): return Columns(table, columns, self.quote_name, col_suffixes=col_suffixes) @@ -1575,6 +1582,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): diff --git a/django/db/backends/sqlite3/schema.py b/django/db/backends/sqlite3/schema.py index 095304c095fa..34dde4f0e1c1 100644 --- a/django/db/backends/sqlite3/schema.py +++ b/django/db/backends/sqlite3/schema.py @@ -14,7 +14,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_delete_column = "ALTER TABLE %(table)s DROP COLUMN %(column)s" diff --git a/django/db/models/__init__.py b/django/db/models/__init__.py index ffca81de91b5..12f298546f56 100644 --- a/django/db/models/__init__.py +++ b/django/db/models/__init__.py @@ -7,6 +7,7 @@ from django.db.models.deletion import ( CASCADE, DO_NOTHING, + ON_DELETE_DB_CHOICES, PROTECT, RESTRICT, SET, @@ -112,4 +113,5 @@ "ManyToOneRel", "ManyToManyRel", "OneToOneRel", + "ON_DELETE_DB_CHOICES", ] diff --git a/django/db/models/deletion.py b/django/db/models/deletion.py index bc26d82e934c..ba896cec907d 100644 --- a/django/db/models/deletion.py +++ b/django/db/models/deletion.py @@ -1,4 +1,5 @@ from collections import Counter, defaultdict +from enum import Enum from functools import partial, reduce from itertools import chain from operator import attrgetter, or_ @@ -19,6 +20,14 @@ def __init__(self, msg, restricted_objects): super().__init__(msg, restricted_objects) +class ON_DELETE_DB_CHOICES(Enum): + DO_NOTHING_DB = "" + CASCADE_DB = "CASCADE" + RESTRICT_DB = "RESTRICT" + SET_NULL_DB = "SET NULL" + SET_DEFAULT_DB = "SET DEFAULT" + + def CASCADE(collector, field, sub_objs, using): collector.collect( sub_objs, @@ -128,6 +137,8 @@ def add(self, objs, source=None, nullable=False, reverse_dependency=False): new_objs = [] model = objs[0].__class__ instances = self.data[model] + print(objs) + print(instances) for obj in objs: if obj not in instances: new_objs.append(obj) @@ -152,6 +163,8 @@ def add_field_update(self, field, value, objs): Schedule a field update. 'objs' must be a homogeneous iterable collection of model instances (e.g. a QuerySet). """ + print("****************") + print("******************") self.field_updates[field, value].append(objs) def add_restricted_objects(self, field, objs): @@ -324,8 +337,10 @@ def collect( model_fast_deletes[related_model].append(field) continue batches = self.get_del_batches(new_objs, [field]) + print(batches) for batch in batches: sub_objs = self.related_objects(related_model, [field], batch) + print(sub_objs) # Non-referenced fields can be deferred if no signal receivers # are connected for the related model as they'll never be # exposed to the user. Skip field deferring when some @@ -363,8 +378,10 @@ def collect( ) for related_model, related_fields in model_fast_deletes.items(): batches = self.get_del_batches(new_objs, related_fields) + print(batches) for batch in batches: sub_objs = self.related_objects(related_model, related_fields, batch) + print(sub_objs) self.fast_deletes.append(sub_objs) for field in model._meta.private_fields: if hasattr(field, "bulk_related_objects"): diff --git a/django/db/models/fields/related.py b/django/db/models/fields/related.py index 2c0527d2b742..d8aa01218385 100644 --- a/django/db/models/fields/related.py +++ b/django/db/models/fields/related.py @@ -11,7 +11,13 @@ from django.db.backends import utils from django.db.models import 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, + DO_NOTHING, + ON_DELETE_DB_CHOICES, + SET_DEFAULT, + SET_NULL, +) from django.db.models.query_utils import PathInfo from django.db.models.utils import make_model_tuple from django.utils.deprecation import RemovedInDjango60Warning @@ -944,6 +950,7 @@ def __init__( self, to, on_delete, + on_delete_db=ON_DELETE_DB_CHOICES.DO_NOTHING_DB, related_name=None, related_query_name=None, limit_choices_to=None, @@ -972,7 +979,6 @@ def __init__( to_field = to_field or (to._meta.pk and to._meta.pk.name) if not callable(on_delete): raise TypeError("on_delete must be callable.") - kwargs["rel"] = self.rel_class( self, to, @@ -982,6 +988,7 @@ def __init__( limit_choices_to=limit_choices_to, parent_link=parent_link, on_delete=on_delete, + on_delete_db=on_delete_db, ) kwargs.setdefault("db_index", True) @@ -1005,6 +1012,7 @@ def check(self, **kwargs): *super().check(**kwargs), *self._check_on_delete(), *self._check_unique(), + *self._check_on_delete_db(), ] def _check_on_delete(self): @@ -1051,6 +1059,25 @@ def _check_unique(self, **kwargs): else [] ) + def _check_on_delete_db(self, **kwargs): + on_delete = getattr(self.remote_field, "on_delete", None) + on_delete_db = getattr(self.remote_field, "on_delete_db", None) + + if ( + on_delete_db != ON_DELETE_DB_CHOICES.DO_NOTHING_DB + and on_delete != DO_NOTHING + ): + return [ + checks.Error( + "The on_delete must be set to on_delete=DO_NOTHING to work with" + "on_delete_db", + hint="Remove the on_delete_db or set on_delete=DO_NOTHING", + obj=self, + id="fields.E322", + ) + ] + return [] + def deconstruct(self): name, path, args, kwargs = super().deconstruct() del kwargs["to_fields"] diff --git a/django/db/models/fields/reverse_related.py b/django/db/models/fields/reverse_related.py index c74e92ba89f1..acce3003a166 100644 --- a/django/db/models/fields/reverse_related.py +++ b/django/db/models/fields/reverse_related.py @@ -47,6 +47,7 @@ def __init__( limit_choices_to=None, parent_link=False, on_delete=None, + on_delete_db=None, ): self.field = field self.model = to @@ -55,6 +56,7 @@ def __init__( self.limit_choices_to = {} if limit_choices_to is None else limit_choices_to self.parent_link = parent_link self.on_delete = on_delete + self.on_delete_db = on_delete_db self.symmetrical = False self.multiple = True @@ -279,6 +281,7 @@ def __init__( limit_choices_to=None, parent_link=False, on_delete=None, + on_delete_db=None, ): super().__init__( field, @@ -288,6 +291,7 @@ def __init__( limit_choices_to=limit_choices_to, parent_link=parent_link, on_delete=on_delete, + on_delete_db=on_delete_db, ) self.field_name = field_name @@ -334,6 +338,7 @@ def __init__( limit_choices_to=None, parent_link=False, on_delete=None, + on_delete_db=None, ): super().__init__( field, @@ -344,6 +349,7 @@ def __init__( limit_choices_to=limit_choices_to, parent_link=parent_link, on_delete=on_delete, + on_delete_db=on_delete_db, ) self.multiple = False diff --git a/tests/db_cascade_foreignkey/__init__.py b/tests/db_cascade_foreignkey/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tests/db_cascade_foreignkey/models.py b/tests/db_cascade_foreignkey/models.py new file mode 100644 index 000000000000..7d5982b64ef1 --- /dev/null +++ b/tests/db_cascade_foreignkey/models.py @@ -0,0 +1,25 @@ +from django.db import models + + +class Foo(models.Model): + """Initial model named Foo""" + + pass + + +class Bar(models.Model): + """First level foreignkey child for Foo + Implemented using database level cascading""" + + foo = models.ForeignKey( + Foo, + on_delete=models.DO_NOTHING, + on_delete_db=models.ON_DELETE_DB_CHOICES.CASCADE_DB, + ) + + +class Baz(models.Model): + """Second level foreignkey child for Foo + Implemented using in python cascading""" + + bar = models.ForeignKey(Bar, on_delete=models.CASCADE) diff --git a/tests/db_cascade_foreignkey/tests.py b/tests/db_cascade_foreignkey/tests.py new file mode 100644 index 000000000000..2ac00718d623 --- /dev/null +++ b/tests/db_cascade_foreignkey/tests.py @@ -0,0 +1,27 @@ +from django.test import TestCase + +from .models import Bar, Baz, Foo + + +class DatabaseLevelCascadeTests(TestCase): + # def test_foreign_key_on_delete_db_cascade(self): + # parent = Parent.objects.create(name="Akash") + # Child.objects.create(name="Akash", parent=parent) + # print(Child.objects.all()) + # parent.delete() + # print(Child.objects.all()) + + # def test_foreign_key_on_delete_db_do_nothing(self): + # parent = Parent.objects.create(name="Akash") + # Child.objects.create(name="Akash", parent=parent) + # print(Child.objects.all()) + # parent.delete() + # print(Child.objects.all()) + + def test_foreign_key_on_delete_db(self): + foo = Foo.objects.create() + bar = Bar.objects.create(foo=foo) + Baz.objects.create(bar=bar) + Baz.objects.create(bar=bar) + Baz.objects.create(bar=bar) + bar.delete() From fba241b2b012ee4f80efe9079179f45730f7ae93 Mon Sep 17 00:00:00 2001 From: Akash Kumar Sen Date: Fri, 12 May 2023 07:30:59 +0530 Subject: [PATCH 02/60] Adding tests and checks --- django/db/models/__init__.py | 2 + django/db/models/deletion.py | 12 +- django/db/models/fields/related.py | 62 ++++++++- .../__init__.py | 0 tests/db_cascade/models.py | 82 +++++++++++ tests/db_cascade/tests.py | 57 ++++++++ tests/db_cascade_checks/__init__.py | 0 tests/db_cascade_checks/models.py | 15 ++ tests/db_cascade_checks/tests.py | 129 ++++++++++++++++++ tests/db_cascade_foreignkey/models.py | 25 ---- tests/db_cascade_foreignkey/tests.py | 27 ---- 11 files changed, 346 insertions(+), 65 deletions(-) rename tests/{db_cascade_foreignkey => db_cascade}/__init__.py (100%) create mode 100644 tests/db_cascade/models.py create mode 100644 tests/db_cascade/tests.py create mode 100644 tests/db_cascade_checks/__init__.py create mode 100644 tests/db_cascade_checks/models.py create mode 100644 tests/db_cascade_checks/tests.py delete mode 100644 tests/db_cascade_foreignkey/models.py delete mode 100644 tests/db_cascade_foreignkey/tests.py diff --git a/django/db/models/__init__.py b/django/db/models/__init__.py index 12f298546f56..bba293bb3976 100644 --- a/django/db/models/__init__.py +++ b/django/db/models/__init__.py @@ -6,6 +6,7 @@ from django.db.models.constraints import __all__ as constraints_all from django.db.models.deletion import ( CASCADE, + DB_CASCADE, DO_NOTHING, ON_DELETE_DB_CHOICES, PROTECT, @@ -114,4 +115,5 @@ "ManyToManyRel", "OneToOneRel", "ON_DELETE_DB_CHOICES", + "DB_CASCADE", ] diff --git a/django/db/models/deletion.py b/django/db/models/deletion.py index ba896cec907d..5f9bed7ff140 100644 --- a/django/db/models/deletion.py +++ b/django/db/models/deletion.py @@ -92,6 +92,10 @@ def DO_NOTHING(collector, field, sub_objs, using): pass +def DB_CASCADE(collector, field, sub_objs, using): + pass + + 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 deletion. @@ -137,8 +141,6 @@ def add(self, objs, source=None, nullable=False, reverse_dependency=False): new_objs = [] model = objs[0].__class__ instances = self.data[model] - print(objs) - print(instances) for obj in objs: if obj not in instances: new_objs.append(obj) @@ -163,8 +165,6 @@ def add_field_update(self, field, value, objs): Schedule a field update. 'objs' must be a homogeneous iterable collection of model instances (e.g. a QuerySet). """ - print("****************") - print("******************") self.field_updates[field, value].append(objs) def add_restricted_objects(self, field, objs): @@ -337,10 +337,8 @@ def collect( model_fast_deletes[related_model].append(field) continue batches = self.get_del_batches(new_objs, [field]) - print(batches) for batch in batches: sub_objs = self.related_objects(related_model, [field], batch) - print(sub_objs) # Non-referenced fields can be deferred if no signal receivers # are connected for the related model as they'll never be # exposed to the user. Skip field deferring when some @@ -378,10 +376,8 @@ def collect( ) for related_model, related_fields in model_fast_deletes.items(): batches = self.get_del_batches(new_objs, related_fields) - print(batches) for batch in batches: sub_objs = self.related_objects(related_model, related_fields, batch) - print(sub_objs) self.fast_deletes.append(sub_objs) for field in model._meta.private_fields: if hasattr(field, "bulk_related_objects"): diff --git a/django/db/models/fields/related.py b/django/db/models/fields/related.py index d8aa01218385..68292429dd03 100644 --- a/django/db/models/fields/related.py +++ b/django/db/models/fields/related.py @@ -13,7 +13,7 @@ from django.db.models.constants import LOOKUP_SEP from django.db.models.deletion import ( CASCADE, - DO_NOTHING, + DB_CASCADE, ON_DELETE_DB_CHOICES, SET_DEFAULT, SET_NULL, @@ -1059,23 +1059,75 @@ def _check_unique(self, **kwargs): else [] ) + def has_related_models_with_db_cascading(self, model): + stack = [model] + while stack: + curr_model = stack.pop() + on_delete = getattr(self.remote_field, "on_delete", None) + related_models = [ + rel.related_model + for rel in curr_model._meta.get_fields() + if rel.related_model and not rel.auto_created + ] + has_related_cascade_db = any( + any( + ( + isinstance(rel, ForeignKey) + and hasattr(rel.remote_field, "on_delete") + and rel.remote_field.on_delete == DB_CASCADE + and on_delete != DB_CASCADE + ) + for rel in model_ptr._meta.get_fields() + ) + for model_ptr in related_models + ) + + if has_related_cascade_db: + return True + + stack.extend(related_models) + + return False + def _check_on_delete_db(self, **kwargs): on_delete = getattr(self.remote_field, "on_delete", None) on_delete_db = getattr(self.remote_field, "on_delete_db", None) if ( on_delete_db != ON_DELETE_DB_CHOICES.DO_NOTHING_DB - and on_delete != DO_NOTHING + and on_delete != DB_CASCADE ): return [ checks.Error( - "The on_delete must be set to on_delete=DO_NOTHING to work with" - "on_delete_db", - hint="Remove the on_delete_db or set on_delete=DO_NOTHING", + "The on_delete must be set to on_delete=models.DB_CASCADE to work" + " with on_delete_db", + hint="Remove the on_delete_db or set on_delete=models.DB_CASCADE", obj=self, id="fields.E322", ) ] + elif on_delete_db == ON_DELETE_DB_CHOICES.SET_NULL_DB and not self.null: + return [ + checks.Error( + "Field specifies on_delete_db=SET_NULL_DB, but cannot be null.", + hint=( + "Set null=True argument on the field, or change the " + "on_delete_db rule." + ), + obj=self, + id="fields.E324", + ) + ] + elif self.has_related_models_with_db_cascading(self.model): + return [ + checks.Error( + "Using normal cascading with DB cascading referenced model is " + "prohibited", + hint="Use database level cascading for foreignkeys", + obj=self, + id="fields.E323", + ) + ] return [] def deconstruct(self): diff --git a/tests/db_cascade_foreignkey/__init__.py b/tests/db_cascade/__init__.py similarity index 100% rename from tests/db_cascade_foreignkey/__init__.py rename to tests/db_cascade/__init__.py diff --git a/tests/db_cascade/models.py b/tests/db_cascade/models.py new file mode 100644 index 000000000000..b2727820ea0a --- /dev/null +++ b/tests/db_cascade/models.py @@ -0,0 +1,82 @@ +from django.db import models + + +class Foo(models.Model): + """Initial model named Foo""" + + pass + + +class Bar(models.Model): + """First level foreignkey child for Foo + Implemented using database level cascading""" + + foo = models.ForeignKey( + Foo, + on_delete=models.DB_CASCADE, + on_delete_db=models.ON_DELETE_DB_CHOICES.CASCADE_DB, + ) + + +class Baz(models.Model): + """Second level foreignkey child for Foo + Implemented using in DB cascading""" + + bar = models.ForeignKey( + Bar, + on_delete=models.DB_CASCADE, + on_delete_db=models.ON_DELETE_DB_CHOICES.CASCADE_DB, + ) + + +# class Fiz(models.Model): +# """Third level foreignkey child for Foo +# Implemented using in python cascading""" +# baz = models.ForeignKey( +# Baz, +# on_delete=models.CASCADE +# ) + + +class RestrictBar(models.Model): + """First level child of foo with cascading set to restrict""" + + foo = models.ForeignKey( + Foo, + on_delete=models.DB_CASCADE, + on_delete_db=models.ON_DELETE_DB_CHOICES.RESTRICT_DB, + ) + + +class RestrictBaz(models.Model): + """Second level child of foo with cascading set to restrict""" + + bar = models.ForeignKey( + Bar, + on_delete=models.DB_CASCADE, + on_delete_db=models.ON_DELETE_DB_CHOICES.RESTRICT_DB, + ) + + +class SetNullBar(models.Model): + """First level child of foo with cascading set to null""" + + foo = models.ForeignKey( + Foo, + on_delete=models.DB_CASCADE, + on_delete_db=models.ON_DELETE_DB_CHOICES.SET_NULL_DB, + null=True, + ) + another_field = models.CharField(max_length=20) + + +class SetNullBaz(models.Model): + """Second level child of foo with cascading set to null""" + + bar = models.ForeignKey( + Bar, + on_delete=models.DB_CASCADE, + on_delete_db=models.ON_DELETE_DB_CHOICES.SET_NULL_DB, + null=True, + ) + another_field = models.CharField(max_length=20) diff --git a/tests/db_cascade/tests.py b/tests/db_cascade/tests.py new file mode 100644 index 000000000000..2e79424b5fe5 --- /dev/null +++ b/tests/db_cascade/tests.py @@ -0,0 +1,57 @@ +from django.db import IntegrityError +from django.test import TestCase + +from .models import Bar, Baz, Foo, RestrictBar, RestrictBaz, SetNullBar, SetNullBaz + + +class DatabaseLevelCascadeTests(TestCase): + def test_deletion_on_nested_cascades(self): + foo = Foo.objects.create() + bar = Bar.objects.create(foo=foo) + baz = Baz.objects.create(bar=bar) + + self.assertEqual(bar, Bar.objects.get(pk=bar.pk)) + self.assertEqual(baz, Baz.objects.get(pk=bar.pk)) + + foo.delete() + + with self.assertRaises(Bar.DoesNotExist): + Bar.objects.get(pk=bar.pk) + + with self.assertRaises(Baz.DoesNotExist): + Baz.objects.get(pk=baz.pk) + + def test_restricted_deletion(self): + foo = Foo.objects.create() + RestrictBar.objects.create(foo=foo) + + with self.assertRaises(IntegrityError): + foo.delete() + + def test_restricted_deletion_by_cascade(self): + foo = Foo.objects.create() + bar = Bar.objects.create(foo=foo) + RestrictBaz.objects.create(bar=bar) + with self.assertRaises(IntegrityError): + foo.delete() + + def test_deletion_on_set_null(self): + foo = Foo.objects.create() + bar = SetNullBar.objects.create(foo=foo, another_field="Some Value") + foo.delete() + orphan_bar = SetNullBar.objects.get(pk=bar.pk) + self.assertEqual(bar.pk, orphan_bar.pk) + self.assertEqual(bar.another_field, orphan_bar.another_field) + self.assertNotEqual(bar.foo, orphan_bar.foo) + self.assertIsNone(orphan_bar.foo) + + def test_set_null_on_cascade_deletion(self): + foo = Foo.objects.create() + bar = Bar.objects.create(foo=foo) + baz = SetNullBaz.objects.create(bar=bar, another_field="Some Value") + foo.delete() + orphan_baz = SetNullBaz.objects.get(pk=baz.pk) + self.assertEqual(baz.pk, orphan_baz.pk) + self.assertEqual(baz.another_field, orphan_baz.another_field) + self.assertNotEqual(baz.bar, orphan_baz.bar) + self.assertIsNone(orphan_baz.bar) diff --git a/tests/db_cascade_checks/__init__.py b/tests/db_cascade_checks/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tests/db_cascade_checks/models.py b/tests/db_cascade_checks/models.py new file mode 100644 index 000000000000..367086c58edc --- /dev/null +++ b/tests/db_cascade_checks/models.py @@ -0,0 +1,15 @@ +from django.db import models + + +class Foo(models.Model): + """Initial model named Foo""" + + pass + + +class Bar(models.Model): + foo = models.ForeignKey( + Foo, + on_delete=models.DB_CASCADE, + on_delete_db=models.ON_DELETE_DB_CHOICES.CASCADE_DB, + ) diff --git a/tests/db_cascade_checks/tests.py b/tests/db_cascade_checks/tests.py new file mode 100644 index 000000000000..8636b0a4a72a --- /dev/null +++ b/tests/db_cascade_checks/tests.py @@ -0,0 +1,129 @@ +from django.core.checks import Error +from django.db import models +from django.test import TestCase + +from .models import Bar, Foo + + +class DatabaseLevelCascadeCheckTests(TestCase): + def test_system_check_on_on_delete_db_combination(self): + class MixedBar(models.Model): + foo = models.ForeignKey( + Foo, + on_delete=models.CASCADE, + on_delete_db=models.ON_DELETE_DB_CHOICES.CASCADE_DB, + ) + + mixed_bar_field = MixedBar._meta.get_field("foo") + self.assertEqual( + mixed_bar_field.check(), + [ + Error( + "The on_delete must be set to on_delete=models.DB_CASCADE to work" + " with on_delete_db", + hint="Remove the on_delete_db or set on_delete=models.DB_CASCADE", + obj=mixed_bar_field, + id="fields.E322", + ) + ], + ) + + def test_system_check_on_nested_db_with_non_db_cascading(self): + class BadBar(models.Model): + foo = models.ForeignKey(Foo, on_delete=models.CASCADE) + + class Baz(models.Model): + """First level child""" + + bar = models.ForeignKey( + Bar, + on_delete=models.CASCADE, + # on_delete_db=models.CASCADE + ) + + class GoodBaz(models.Model): + """First level child""" + + bar = models.ForeignKey( + Bar, + on_delete=models.DB_CASCADE, + on_delete_db=models.ON_DELETE_DB_CHOICES.CASCADE_DB, + ) + + class AnotherBaz(models.Model): + """Second level child""" + + bar = models.ForeignKey( + BadBar, + on_delete=models.DB_CASCADE, + on_delete_db=models.ON_DELETE_DB_CHOICES.CASCADE_DB, + ) + + class Fiz(models.Model): + """Second level child""" + + baz = models.ForeignKey(Baz, on_delete=models.CASCADE) + + baz_field = Baz._meta.get_field("bar") + self.assertEqual( + baz_field.check(), + [ + Error( + "Using normal cascading with DB cascading referenced model is " + "prohibited", + hint="Use database level cascading for foreignkeys", + obj=baz_field, + id="fields.E323", + ), + ], + ) + + fiz_field = Fiz._meta.get_field("baz") + self.assertEqual( + fiz_field.check(), + [ + Error( + "Using normal cascading with DB cascading referenced model is " + "prohibited", + hint="Use database level cascading for foreignkeys", + obj=fiz_field, + id="fields.E323", + ), + ], + ) + + good_baz_field = GoodBaz._meta.get_field("bar") + self.assertEqual(good_baz_field.check(), []) + + bad_bar_field = BadBar._meta.get_field("foo") + self.assertEqual(bad_bar_field.check(), []) + + bar_field = Bar._meta.get_field("foo") + self.assertEqual(bar_field.check(), []) + + another_baz_field = AnotherBaz._meta.get_field("bar") + self.assertEqual(another_baz_field.check(), []) + + def test_null_condition_with_set_null_db(self): + class SetNullDbNotNullModel(models.Model): + foo = models.ForeignKey( + Foo, + on_delete=models.DB_CASCADE, + on_delete_db=models.ON_DELETE_DB_CHOICES.SET_NULL_DB, + ) + + field = SetNullDbNotNullModel._meta.get_field("foo") + self.assertEqual( + field.check(), + [ + Error( + "Field specifies on_delete_db=SET_NULL_DB, but cannot be null.", + hint=( + "Set null=True argument on the field, or change the " + "on_delete_db rule." + ), + obj=field, + id="fields.E324", + ) + ], + ) diff --git a/tests/db_cascade_foreignkey/models.py b/tests/db_cascade_foreignkey/models.py deleted file mode 100644 index 7d5982b64ef1..000000000000 --- a/tests/db_cascade_foreignkey/models.py +++ /dev/null @@ -1,25 +0,0 @@ -from django.db import models - - -class Foo(models.Model): - """Initial model named Foo""" - - pass - - -class Bar(models.Model): - """First level foreignkey child for Foo - Implemented using database level cascading""" - - foo = models.ForeignKey( - Foo, - on_delete=models.DO_NOTHING, - on_delete_db=models.ON_DELETE_DB_CHOICES.CASCADE_DB, - ) - - -class Baz(models.Model): - """Second level foreignkey child for Foo - Implemented using in python cascading""" - - bar = models.ForeignKey(Bar, on_delete=models.CASCADE) diff --git a/tests/db_cascade_foreignkey/tests.py b/tests/db_cascade_foreignkey/tests.py deleted file mode 100644 index 2ac00718d623..000000000000 --- a/tests/db_cascade_foreignkey/tests.py +++ /dev/null @@ -1,27 +0,0 @@ -from django.test import TestCase - -from .models import Bar, Baz, Foo - - -class DatabaseLevelCascadeTests(TestCase): - # def test_foreign_key_on_delete_db_cascade(self): - # parent = Parent.objects.create(name="Akash") - # Child.objects.create(name="Akash", parent=parent) - # print(Child.objects.all()) - # parent.delete() - # print(Child.objects.all()) - - # def test_foreign_key_on_delete_db_do_nothing(self): - # parent = Parent.objects.create(name="Akash") - # Child.objects.create(name="Akash", parent=parent) - # print(Child.objects.all()) - # parent.delete() - # print(Child.objects.all()) - - def test_foreign_key_on_delete_db(self): - foo = Foo.objects.create() - bar = Bar.objects.create(foo=foo) - Baz.objects.create(bar=bar) - Baz.objects.create(bar=bar) - Baz.objects.create(bar=bar) - bar.delete() From 2776dddfaa60d823fe5f8c3902cd8256ee9ecbba Mon Sep 17 00:00:00 2001 From: Akash Kumar Sen Date: Fri, 12 May 2023 11:58:42 +0530 Subject: [PATCH 03/60] Remove recursive parent call --- django/db/models/fields/related.py | 46 ++++++++++++++---------------- tests/db_cascade_checks/tests.py | 43 ---------------------------- 2 files changed, 21 insertions(+), 68 deletions(-) diff --git a/django/db/models/fields/related.py b/django/db/models/fields/related.py index 68292429dd03..ac8796c58b87 100644 --- a/django/db/models/fields/related.py +++ b/django/db/models/fields/related.py @@ -1060,34 +1060,30 @@ def _check_unique(self, **kwargs): ) def has_related_models_with_db_cascading(self, model): - stack = [model] - while stack: - curr_model = stack.pop() - on_delete = getattr(self.remote_field, "on_delete", None) - related_models = [ - rel.related_model - for rel in curr_model._meta.get_fields() - if rel.related_model and not rel.auto_created - ] - has_related_cascade_db = any( - any( - ( - isinstance(rel, ForeignKey) - and hasattr(rel.remote_field, "on_delete") - and rel.remote_field.on_delete == DB_CASCADE - and on_delete != DB_CASCADE - ) - for rel in model_ptr._meta.get_fields() + # get all related models + if not hasattr(model, "_meta"): + return False + + on_delete = getattr(self.remote_field, "on_delete", None) + related_models = [ + rel.related_model + for rel in model._meta.get_fields() + if rel.related_model and not rel.auto_created + ] + has_related_cascade_db = any( + any( + ( + isinstance(rel, ForeignKey) + and hasattr(rel.remote_field, "on_delete") + and rel.remote_field.on_delete == DB_CASCADE + and on_delete != DB_CASCADE ) - for model_ptr in related_models + for rel in model._meta.get_fields() ) + for model in [item for item in related_models if hasattr(item, "_meta")] + ) - if has_related_cascade_db: - return True - - stack.extend(related_models) - - return False + return has_related_cascade_db def _check_on_delete_db(self, **kwargs): on_delete = getattr(self.remote_field, "on_delete", None) diff --git a/tests/db_cascade_checks/tests.py b/tests/db_cascade_checks/tests.py index 8636b0a4a72a..38fa13366f68 100644 --- a/tests/db_cascade_checks/tests.py +++ b/tests/db_cascade_checks/tests.py @@ -41,29 +41,6 @@ class Baz(models.Model): # on_delete_db=models.CASCADE ) - class GoodBaz(models.Model): - """First level child""" - - bar = models.ForeignKey( - Bar, - on_delete=models.DB_CASCADE, - on_delete_db=models.ON_DELETE_DB_CHOICES.CASCADE_DB, - ) - - class AnotherBaz(models.Model): - """Second level child""" - - bar = models.ForeignKey( - BadBar, - on_delete=models.DB_CASCADE, - on_delete_db=models.ON_DELETE_DB_CHOICES.CASCADE_DB, - ) - - class Fiz(models.Model): - """Second level child""" - - baz = models.ForeignKey(Baz, on_delete=models.CASCADE) - baz_field = Baz._meta.get_field("bar") self.assertEqual( baz_field.check(), @@ -78,32 +55,12 @@ class Fiz(models.Model): ], ) - fiz_field = Fiz._meta.get_field("baz") - self.assertEqual( - fiz_field.check(), - [ - Error( - "Using normal cascading with DB cascading referenced model is " - "prohibited", - hint="Use database level cascading for foreignkeys", - obj=fiz_field, - id="fields.E323", - ), - ], - ) - - good_baz_field = GoodBaz._meta.get_field("bar") - self.assertEqual(good_baz_field.check(), []) - bad_bar_field = BadBar._meta.get_field("foo") self.assertEqual(bad_bar_field.check(), []) bar_field = Bar._meta.get_field("foo") self.assertEqual(bar_field.check(), []) - another_baz_field = AnotherBaz._meta.get_field("bar") - self.assertEqual(another_baz_field.check(), []) - def test_null_condition_with_set_null_db(self): class SetNullDbNotNullModel(models.Model): foo = models.ForeignKey( From b6368dbd3793d127516d111c761c24f50b0475fb Mon Sep 17 00:00:00 2001 From: Akash Kumar Sen Date: Fri, 12 May 2023 14:21:58 +0530 Subject: [PATCH 04/60] Fixing postgres tests --- django/db/backends/base/schema.py | 6 +++++- tests/schema/tests.py | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/django/db/backends/base/schema.py b/django/db/backends/base/schema.py index a926979a4336..1dc1b6a8ad86 100644 --- a/django/db/backends/base/schema.py +++ b/django/db/backends/base/schema.py @@ -13,6 +13,7 @@ ) from django.db.backends.utils import names_digest, split_identifier, truncate_name from django.db.models import Deferrable, Index +from django.db.models.deletion import ON_DELETE_DB_CHOICES from django.db.models.sql import Query from django.db.transaction import TransactionManagementError, atomic from django.utils import timezone @@ -698,6 +699,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: @@ -1484,7 +1486,9 @@ def _rename_index_sql(self, model, old_name, new_name): ) def _create_on_delete_sql(self, model, field): - on_delete_db = getattr(field.remote_field, "on_delete_db", None) + on_delete_db = getattr( + field.remote_field, "on_delete_db", ON_DELETE_DB_CHOICES.DO_NOTHING_DB + ) if on_delete_db.value: return "ON DELETE %s" % on_delete_db.value return "" diff --git a/tests/schema/tests.py b/tests/schema/tests.py index d81a01b41dc3..e8b7313f19e4 100644 --- a/tests/schema/tests.py +++ b/tests/schema/tests.py @@ -4249,6 +4249,7 @@ def get_field(*args, field_class=IntegerField, **kwargs): "to_table": editor.quote_name(table), "to_column": editor.quote_name(model._meta.auto_field.column), "deferrable": connection.ops.deferrable_sql(), + "on_delete_db": editor._create_on_delete_sql(model, field), } ) self.assertIn( From 2fa78d4f9b099b4fc6f8b607c00297b9c459c35d Mon Sep 17 00:00:00 2001 From: Akash Kumar Sen Date: Fri, 12 May 2023 17:23:46 +0530 Subject: [PATCH 05/60] DB specific schema upgrade --- django/db/backends/mysql/schema.py | 2 +- django/db/backends/oracle/schema.py | 3 ++- django/db/backends/postgresql/schema.py | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/django/db/backends/mysql/schema.py b/django/db/backends/mysql/schema.py index 2ac287328b71..ee391ee64d1d 100644 --- a/django/db/backends/mysql/schema.py +++ b/django/db/backends/mysql/schema.py @@ -17,7 +17,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/schema.py b/django/db/backends/oracle/schema.py index aca6d839621b..97ea8e2855a5 100644 --- a/django/db/backends/oracle/schema.py +++ b/django/db/backends/oracle/schema.py @@ -21,7 +21,8 @@ class DatabaseSchemaEditor(BaseDatabaseSchemaEditor): sql_delete_column = "ALTER TABLE %(table)s DROP COLUMN %(column)s" 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 18899a6a2bc1..847168ebe460 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. From 0fba2c54a5ada32c0691361771c46df6a2710e70 Mon Sep 17 00:00:00 2001 From: Akash Kumar Sen Date: Fri, 12 May 2023 17:59:08 +0530 Subject: [PATCH 06/60] Fixing mysql bugs --- tests/db_cascade_checks/tests.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/db_cascade_checks/tests.py b/tests/db_cascade_checks/tests.py index 38fa13366f68..fda7053248d4 100644 --- a/tests/db_cascade_checks/tests.py +++ b/tests/db_cascade_checks/tests.py @@ -1,6 +1,6 @@ from django.core.checks import Error -from django.db import models -from django.test import TestCase +from django.db import OperationalError, models +from django.test import TestCase, skipIfDBFeature, skipUnlessDBFeature from .models import Bar, Foo @@ -61,6 +61,7 @@ class Baz(models.Model): bar_field = Bar._meta.get_field("foo") self.assertEqual(bar_field.check(), []) + @skipIfDBFeature("supports_mysql") def test_null_condition_with_set_null_db(self): class SetNullDbNotNullModel(models.Model): foo = models.ForeignKey( @@ -84,3 +85,8 @@ class SetNullDbNotNullModel(models.Model): ) ], ) + + @skipUnlessDBFeature("supports_mysql") + def test_null_condition_with_set_null_db_only_mysql(self): + with self.assertRaises(OperationalError): + self.test_null_condition_with_set_null_db() From c43a01ef319252735f1f42cea6ff19a78c747ea0 Mon Sep 17 00:00:00 2001 From: Akash Kumar Sen Date: Fri, 12 May 2023 18:48:27 +0530 Subject: [PATCH 07/60] Fixing mysql bugs - 2 --- tests/db_cascade_checks/tests.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/tests/db_cascade_checks/tests.py b/tests/db_cascade_checks/tests.py index fda7053248d4..bb908137944d 100644 --- a/tests/db_cascade_checks/tests.py +++ b/tests/db_cascade_checks/tests.py @@ -1,6 +1,6 @@ from django.core.checks import Error -from django.db import OperationalError, models -from django.test import TestCase, skipIfDBFeature, skipUnlessDBFeature +from django.db import models +from django.test import TestCase from .models import Bar, Foo @@ -61,7 +61,6 @@ class Baz(models.Model): bar_field = Bar._meta.get_field("foo") self.assertEqual(bar_field.check(), []) - @skipIfDBFeature("supports_mysql") def test_null_condition_with_set_null_db(self): class SetNullDbNotNullModel(models.Model): foo = models.ForeignKey( @@ -70,6 +69,9 @@ class SetNullDbNotNullModel(models.Model): on_delete_db=models.ON_DELETE_DB_CHOICES.SET_NULL_DB, ) + class Meta: + managed = False + field = SetNullDbNotNullModel._meta.get_field("foo") self.assertEqual( field.check(), @@ -85,8 +87,3 @@ class SetNullDbNotNullModel(models.Model): ) ], ) - - @skipUnlessDBFeature("supports_mysql") - def test_null_condition_with_set_null_db_only_mysql(self): - with self.assertRaises(OperationalError): - self.test_null_condition_with_set_null_db() From cd3f2454ba041ad77499d3d42356d215cb484a0d Mon Sep 17 00:00:00 2001 From: Akash Kumar Sen Date: Fri, 12 May 2023 21:48:49 +0530 Subject: [PATCH 08/60] Adding some more checks --- django/db/models/fields/related.py | 49 +++++++++++++++- tests/db_cascade_checks/tests.py | 93 ++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 3 deletions(-) diff --git a/django/db/models/fields/related.py b/django/db/models/fields/related.py index ac8796c58b87..2fd4bd10a9e8 100644 --- a/django/db/models/fields/related.py +++ b/django/db/models/fields/related.py @@ -1038,6 +1038,46 @@ def _check_on_delete(self): id="fields.E321", ) ] + elif ( + on_delete == DB_CASCADE + and hasattr(self.model, "_meta") + and any( + not parent._meta.abstract + for parent in self.model._meta.get_parent_list() + ) + ): + return [ + checks.Error( + "Field specifies unsupported on_delete=DB_CASCADE, on " + "inherited model", + hint="Set a default value, or change the on_delete rule.", + obj=self, + id="fields.E325", + ) + ] + elif ( + on_delete == DB_CASCADE + and hasattr(self.model, "_meta") + and ( + any( # generic relation + hasattr(field, "bulk_related_objects") + for field in self.model._meta.private_fields + ) + or any( # generic foreign key + hasattr(field, "get_content_type") + for field in self.model._meta.private_fields + ) + ) + ): + return [ + checks.Error( + "Field specifies unsupported on_delete=DB_CASCADE on model " + "declaring a GenericForeignKey.", + hint="Change the on_delete rule.", + obj=self, + id="fields.E345", + ) + ] else: return [] @@ -1059,8 +1099,11 @@ def _check_unique(self, **kwargs): else [] ) - def has_related_models_with_db_cascading(self, model): - # get all related models + def _has_related_models_with_db_cascading(self, model): + """ + If the foreignkey parent has DB cascading and the Current model has non + db cascading return true + """ if not hasattr(model, "_meta"): return False @@ -1114,7 +1157,7 @@ def _check_on_delete_db(self, **kwargs): id="fields.E324", ) ] - elif self.has_related_models_with_db_cascading(self.model): + elif self._has_related_models_with_db_cascading(self.model): return [ checks.Error( "Using normal cascading with DB cascading referenced model is " diff --git a/tests/db_cascade_checks/tests.py b/tests/db_cascade_checks/tests.py index bb908137944d..323106d41145 100644 --- a/tests/db_cascade_checks/tests.py +++ b/tests/db_cascade_checks/tests.py @@ -1,3 +1,5 @@ +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType from django.core.checks import Error from django.db import models from django.test import TestCase @@ -87,3 +89,94 @@ class Meta: ) ], ) + + def test_check_on_inherited_models(self): + class AnotherBar(Bar): + another_foo = models.ForeignKey( + Foo, + on_delete=models.DB_CASCADE, + on_delete_db=models.ON_DELETE_DB_CHOICES.CASCADE_DB, + ) + + class Meta: + managed = False + + class MultipleInheritedBar(Foo, Bar): + another_foo = models.ForeignKey( + Foo, + on_delete=models.DB_CASCADE, + on_delete_db=models.ON_DELETE_DB_CHOICES.CASCADE_DB, + related_name="another_foo", + ) + + field = AnotherBar._meta.get_field("another_foo") + self.assertEqual( + field.check(), + [ + Error( + "Field specifies unsupported on_delete=DB_CASCADE, on " + "inherited model", + hint="Set a default value, or change the on_delete rule.", + obj=field, + id="fields.E325", + ) + ], + ) + + multiple_inheritence_field = MultipleInheritedBar._meta.get_field("another_foo") + self.assertEqual( + multiple_inheritence_field.check(), + [ + Error( + "Field specifies unsupported on_delete=DB_CASCADE, on " + "inherited model", + hint="Set a default value, or change the on_delete rule.", + obj=multiple_inheritence_field, + id="fields.E325", + ) + ], + ) + + def test_check_on_generic_foreign_key(self): + class SomeModel(models.Model): + some_fk = models.ForeignKey( + ContentType, + on_delete=models.DB_CASCADE, + on_delete_db=models.ON_DELETE_DB_CHOICES.CASCADE_DB, + related_name="ctcmnt", + ) + object_id = models.PositiveIntegerField() + content_object = GenericForeignKey("some_fk", "object_id") + + class Meta: + managed = False + + class SomeAnotherModel(models.Model): + another_fk = models.ForeignKey( + ContentType, on_delete=models.CASCADE, related_name="anfk" + ) + object_id = models.PositiveIntegerField() + content_object = GenericForeignKey("another_fk", "object_id") + + class Meta: + managed = False + + comment_field = SomeModel._meta.get_field("some_fk") + self.assertEqual( + comment_field.check(), + [ + Error( + "Field specifies unsupported on_delete=DB_CASCADE on model " + "declaring a GenericForeignKey.", + hint="Change the on_delete rule.", + obj=comment_field, + id="fields.E345", + ) + ], + ) + + photo_field = SomeAnotherModel._meta.get_field("another_fk") + self.assertEqual( + photo_field.check(), + [], + ) From 1684638db15d4856b506e8a99e22fb69440e74a2 Mon Sep 17 00:00:00 2001 From: Akash Kumar Sen Date: Fri, 12 May 2023 22:54:07 +0530 Subject: [PATCH 09/60] Adding some more checks -2 --- tests/db_cascade_checks/tests.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/db_cascade_checks/tests.py b/tests/db_cascade_checks/tests.py index 323106d41145..1aec19c32b56 100644 --- a/tests/db_cascade_checks/tests.py +++ b/tests/db_cascade_checks/tests.py @@ -149,7 +149,7 @@ class SomeModel(models.Model): content_object = GenericForeignKey("some_fk", "object_id") class Meta: - managed = False + abstract = True class SomeAnotherModel(models.Model): another_fk = models.ForeignKey( @@ -159,7 +159,7 @@ class SomeAnotherModel(models.Model): content_object = GenericForeignKey("another_fk", "object_id") class Meta: - managed = False + abstract = True comment_field = SomeModel._meta.get_field("some_fk") self.assertEqual( From cd9444301eb705583ee7b3e861d795c0779a89c3 Mon Sep 17 00:00:00 2001 From: Akash Kumar Sen Date: Sat, 13 May 2023 07:13:59 +0530 Subject: [PATCH 10/60] Cleanup and optimization 1 --- django/db/models/deletion.py | 1 - django/db/models/fields/related.py | 6 ++++-- tests/db_cascade/models.py | 9 --------- tests/db_cascade_checks/tests.py | 12 ++++++++---- 4 files changed, 12 insertions(+), 16 deletions(-) diff --git a/django/db/models/deletion.py b/django/db/models/deletion.py index 5f9bed7ff140..45ecbff91328 100644 --- a/django/db/models/deletion.py +++ b/django/db/models/deletion.py @@ -25,7 +25,6 @@ class ON_DELETE_DB_CHOICES(Enum): CASCADE_DB = "CASCADE" RESTRICT_DB = "RESTRICT" SET_NULL_DB = "SET NULL" - SET_DEFAULT_DB = "SET DEFAULT" def CASCADE(collector, field, sub_objs, using): diff --git a/django/db/models/fields/related.py b/django/db/models/fields/related.py index 2fd4bd10a9e8..22b384ad30ff 100644 --- a/django/db/models/fields/related.py +++ b/django/db/models/fields/related.py @@ -1049,8 +1049,10 @@ def _check_on_delete(self): return [ checks.Error( "Field specifies unsupported on_delete=DB_CASCADE, on " - "inherited model", - hint="Set a default value, or change the on_delete rule.", + "inherited model as it already contains a parent_ptr " + "with in-python cascading options, both cannot be used " + "together", + hint="Change the on_delete rule to other options", obj=self, id="fields.E325", ) diff --git a/tests/db_cascade/models.py b/tests/db_cascade/models.py index b2727820ea0a..9cf101e46cca 100644 --- a/tests/db_cascade/models.py +++ b/tests/db_cascade/models.py @@ -29,15 +29,6 @@ class Baz(models.Model): ) -# class Fiz(models.Model): -# """Third level foreignkey child for Foo -# Implemented using in python cascading""" -# baz = models.ForeignKey( -# Baz, -# on_delete=models.CASCADE -# ) - - class RestrictBar(models.Model): """First level child of foo with cascading set to restrict""" diff --git a/tests/db_cascade_checks/tests.py b/tests/db_cascade_checks/tests.py index 1aec19c32b56..e226a32c2b33 100644 --- a/tests/db_cascade_checks/tests.py +++ b/tests/db_cascade_checks/tests.py @@ -115,8 +115,10 @@ class MultipleInheritedBar(Foo, Bar): [ Error( "Field specifies unsupported on_delete=DB_CASCADE, on " - "inherited model", - hint="Set a default value, or change the on_delete rule.", + "inherited model as it already contains a parent_ptr " + "with in-python cascading options, both cannot be used " + "together", + hint="Change the on_delete rule to other options", obj=field, id="fields.E325", ) @@ -129,8 +131,10 @@ class MultipleInheritedBar(Foo, Bar): [ Error( "Field specifies unsupported on_delete=DB_CASCADE, on " - "inherited model", - hint="Set a default value, or change the on_delete rule.", + "inherited model as it already contains a parent_ptr " + "with in-python cascading options, both cannot be used " + "together", + hint="Change the on_delete rule to other options", obj=multiple_inheritence_field, id="fields.E325", ) From 19454699e3831ee34c7c3f5ca73ac330ce2f9913 Mon Sep 17 00:00:00 2001 From: Akash Kumar Sen Date: Sat, 13 May 2023 09:43:59 +0530 Subject: [PATCH 11/60] Add release notes --- docs/releases/5.0.txt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/releases/5.0.txt b/docs/releases/5.0.txt index d40cd6a4f0c5..c79670d1306e 100644 --- a/docs/releases/5.0.txt +++ b/docs/releases/5.0.txt @@ -123,6 +123,23 @@ sets a database-computed default value. For example:: created = models.DateTimeField(db_default=Now()) circumference = models.FloatField(db_default=2 * Pi()) +Database level cascading support for Foreignkeys +--------------------------------------------------------------- +The ``on_delete`` argument for ``Foreignkey`` now supports an extra option +``DB_CASCADE`` this will behave like ``DO_NOTHING`` in Django, but leverage +the Database level cascading options supported by SQL. + +A new optional argument named ``on_delete_db`` is also passed to the +``Foreignkey``. This will specify the type of database level cascading we are +looking for if the ``on_delete`` is set to ``DB_CASCADE``. + +Supported values of the ``on_delete_db`` argument can be the following:: + + ON_DELETE_DB_CHOICES.DO_NOTHING_DB = Does nothing, used as default value. + ON_DELETE_DB_CHOICES.CASCADE_DB = Deletes the child on parent deletion. + ON_DELETE_DB_CHOICES.RESTRICT_DB = Prevent the deletion if child exists. + ON_DELETE_DB_CHOICES.SET_NULL_DB = Set the value to null in child table. + Minor features -------------- From 86e8e53c6f848c38e9ec5aa56f528740c2843587 Mon Sep 17 00:00:00 2001 From: Akash Kumar Sen Date: Sat, 13 May 2023 10:01:19 +0530 Subject: [PATCH 12/60] Update release notes --- docs/releases/5.0.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/releases/5.0.txt b/docs/releases/5.0.txt index c79670d1306e..b4906cb3c1ad 100644 --- a/docs/releases/5.0.txt +++ b/docs/releases/5.0.txt @@ -123,7 +123,7 @@ sets a database-computed default value. For example:: created = models.DateTimeField(db_default=Now()) circumference = models.FloatField(db_default=2 * Pi()) -Database level cascading support for Foreignkeys +Database level cascading support for Foreignkey --------------------------------------------------------------- The ``on_delete`` argument for ``Foreignkey`` now supports an extra option ``DB_CASCADE`` this will behave like ``DO_NOTHING`` in Django, but leverage From 83876dba34bfd18f86039374633d9cb6335563f9 Mon Sep 17 00:00:00 2001 From: Akash Kumar Sen Date: Sat, 13 May 2023 10:08:41 +0530 Subject: [PATCH 13/60] Update release notes -2 --- docs/releases/5.0.txt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/releases/5.0.txt b/docs/releases/5.0.txt index b4906cb3c1ad..ccc0ff02bed6 100644 --- a/docs/releases/5.0.txt +++ b/docs/releases/5.0.txt @@ -123,7 +123,7 @@ sets a database-computed default value. For example:: created = models.DateTimeField(db_default=Now()) circumference = models.FloatField(db_default=2 * Pi()) -Database level cascading support for Foreignkey +Database level cascading support for Foreignkey field --------------------------------------------------------------- The ``on_delete`` argument for ``Foreignkey`` now supports an extra option ``DB_CASCADE`` this will behave like ``DO_NOTHING`` in Django, but leverage @@ -133,12 +133,12 @@ A new optional argument named ``on_delete_db`` is also passed to the ``Foreignkey``. This will specify the type of database level cascading we are looking for if the ``on_delete`` is set to ``DB_CASCADE``. -Supported values of the ``on_delete_db`` argument can be the following:: +Supported values of the ``on_delete_db`` argument can be the following: - ON_DELETE_DB_CHOICES.DO_NOTHING_DB = Does nothing, used as default value. - ON_DELETE_DB_CHOICES.CASCADE_DB = Deletes the child on parent deletion. - ON_DELETE_DB_CHOICES.RESTRICT_DB = Prevent the deletion if child exists. - ON_DELETE_DB_CHOICES.SET_NULL_DB = Set the value to null in child table. +``ON_DELETE_DB_CHOICES.DO_NOTHING_DB`` : Does nothing, used as default value. +``ON_DELETE_DB_CHOICES.CASCADE_DB`` : Deletes the child on parent deletion. +``ON_DELETE_DB_CHOICES.RESTRICT_DB`` : Prevent the deletion if child exists. +``ON_DELETE_DB_CHOICES.SET_NULL_DB`` : Set the value to null in child table. Minor features -------------- From 04df10e20853c8e549deddf46fac8c289e265225 Mon Sep 17 00:00:00 2001 From: Akash Kumar Sen Date: Sat, 13 May 2023 10:23:55 +0530 Subject: [PATCH 14/60] Fix spelling in docs --- docs/releases/5.0.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/releases/5.0.txt b/docs/releases/5.0.txt index ccc0ff02bed6..6ef22f716f93 100644 --- a/docs/releases/5.0.txt +++ b/docs/releases/5.0.txt @@ -123,14 +123,14 @@ sets a database-computed default value. For example:: created = models.DateTimeField(db_default=Now()) circumference = models.FloatField(db_default=2 * Pi()) -Database level cascading support for Foreignkey field +Database level cascading support for ForeignKey field --------------------------------------------------------------- -The ``on_delete`` argument for ``Foreignkey`` now supports an extra option +The ``on_delete`` argument for ``ForeignKey`` now supports an extra option ``DB_CASCADE`` this will behave like ``DO_NOTHING`` in Django, but leverage the Database level cascading options supported by SQL. A new optional argument named ``on_delete_db`` is also passed to the -``Foreignkey``. This will specify the type of database level cascading we are +``ForeignKey``. This will specify the type of database level cascading we are looking for if the ``on_delete`` is set to ``DB_CASCADE``. Supported values of the ``on_delete_db`` argument can be the following: From 18dd25618231be470a922f35c7085b30e253b249 Mon Sep 17 00:00:00 2001 From: Akash Kumar Sen Date: Tue, 16 May 2023 09:34:22 +0530 Subject: [PATCH 15/60] User DB_* methods instead of a new kwarg --- django/db/backends/base/operations.py | 11 ++++ django/db/backends/base/schema.py | 10 ++-- django/db/backends/mysql/schema.py | 2 +- django/db/backends/oracle/schema.py | 2 +- django/db/backends/postgresql/schema.py | 4 +- django/db/backends/sqlite3/schema.py | 4 +- django/db/models/__init__.py | 6 +- django/db/models/deletion.py | 24 ++++---- django/db/models/fields/related.py | 70 ++++++---------------- django/db/models/fields/reverse_related.py | 6 -- docs/releases/5.0.txt | 18 ++---- tests/db_cascade/models.py | 14 ++--- tests/db_cascade_checks/models.py | 1 - tests/db_cascade_checks/tests.py | 39 ++---------- 14 files changed, 71 insertions(+), 140 deletions(-) diff --git a/django/db/backends/base/operations.py b/django/db/backends/base/operations.py index 6f10e31cd5bc..c6e167ac49a8 100644 --- a/django/db/backends/base/operations.py +++ b/django/db/backends/base/operations.py @@ -260,6 +260,17 @@ 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 during a CREATE TABLE + statement. + """ + if operation in ["CASCADE", "SET NULL", "RESTRICT"]: + return " ON DELETE %s " % operation + if operation == "": + return "" + raise NotImplementedError("ON DELETE %s is not supported." % operation) + def last_executed_query(self, cursor, sql, params): """ Return a string of the query last executed by the given cursor, with diff --git a/django/db/backends/base/schema.py b/django/db/backends/base/schema.py index cb568a25f0f3..cd11700db9c8 100644 --- a/django/db/backends/base/schema.py +++ b/django/db/backends/base/schema.py @@ -13,7 +13,7 @@ ) from django.db.backends.utils import names_digest, split_identifier, truncate_name from django.db.models import NOT_PROVIDED, Deferrable, Index -from django.db.models.deletion import ON_DELETE_DB_CHOICES +from django.db.models.deletion import DatabaseOnDelete from django.db.models.sql import Query from django.db.transaction import TransactionManagementError, atomic from django.utils import timezone @@ -1560,11 +1560,9 @@ def _rename_index_sql(self, model, old_name, new_name): ) def _create_on_delete_sql(self, model, field): - on_delete_db = getattr( - field.remote_field, "on_delete_db", ON_DELETE_DB_CHOICES.DO_NOTHING_DB - ) - if on_delete_db.value: - return "ON DELETE %s" % on_delete_db.value + on_delete = getattr(field.remote_field, "on_delete", None) + if isinstance(on_delete, DatabaseOnDelete): + return on_delete.as_sql(self.connection) return "" def _index_columns(self, table, columns, col_suffixes, opclasses): diff --git a/django/db/backends/mysql/schema.py b/django/db/backends/mysql/schema.py index 0ad8983b9b81..a55aa94168c7 100644 --- a/django/db/backends/mysql/schema.py +++ b/django/db/backends/mysql/schema.py @@ -17,7 +17,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) %(on_delete_db)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/schema.py b/django/db/backends/oracle/schema.py index dc3fe696a694..c163a636a439 100644 --- a/django/db/backends/oracle/schema.py +++ b/django/db/backends/oracle/schema.py @@ -21,7 +21,7 @@ class DatabaseSchemaEditor(BaseDatabaseSchemaEditor): sql_delete_column = "ALTER TABLE %(table)s DROP COLUMN %(column)s" sql_create_column_inline_fk = ( - "CONSTRAINT %(name)s REFERENCES %(to_table)s(%(to_column)s) %(on_delete_db)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" diff --git a/django/db/backends/postgresql/schema.py b/django/db/backends/postgresql/schema.py index da3429402827..b8f366974cc9 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) %(on_delete_db)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 e6d55254f840..565d7ecb7d7c 100644 --- a/django/db/backends/sqlite3/schema.py +++ b/django/db/backends/sqlite3/schema.py @@ -14,8 +14,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) " - "%(on_delete_db)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_delete_column = "ALTER TABLE %(table)s DROP COLUMN %(column)s" diff --git a/django/db/models/__init__.py b/django/db/models/__init__.py index bba293bb3976..3200b4694c7b 100644 --- a/django/db/models/__init__.py +++ b/django/db/models/__init__.py @@ -7,8 +7,9 @@ from django.db.models.deletion import ( CASCADE, DB_CASCADE, + DB_RESTRICT, + DB_SET_NULL, DO_NOTHING, - ON_DELETE_DB_CHOICES, PROTECT, RESTRICT, SET, @@ -114,6 +115,7 @@ "ManyToOneRel", "ManyToManyRel", "OneToOneRel", - "ON_DELETE_DB_CHOICES", "DB_CASCADE", + "DB_RESTRICT", + "DB_SET_NULL", ] diff --git a/django/db/models/deletion.py b/django/db/models/deletion.py index 45ecbff91328..10bf57f02af0 100644 --- a/django/db/models/deletion.py +++ b/django/db/models/deletion.py @@ -1,5 +1,4 @@ from collections import Counter, defaultdict -from enum import Enum from functools import partial, reduce from itertools import chain from operator import attrgetter, or_ @@ -20,13 +19,6 @@ def __init__(self, msg, restricted_objects): super().__init__(msg, restricted_objects) -class ON_DELETE_DB_CHOICES(Enum): - DO_NOTHING_DB = "" - CASCADE_DB = "CASCADE" - RESTRICT_DB = "RESTRICT" - SET_NULL_DB = "SET NULL" - - def CASCADE(collector, field, sub_objs, using): collector.collect( sub_objs, @@ -91,8 +83,20 @@ def DO_NOTHING(collector, field, sub_objs, using): pass -def DB_CASCADE(collector, field, sub_objs, using): - pass +class DatabaseOnDelete: + def __init__(self, operation): + self.operation = operation + + def __call__(self, collector, field, sub_objs, using): + pass + + def as_sql(self, connection): + return connection.ops.fk_on_delete_sql(self.operation) + + +DB_CASCADE = DatabaseOnDelete("CASCADE") +DB_SET_NULL = DatabaseOnDelete("SET NULL") +DB_RESTRICT = DatabaseOnDelete("RESTRICT") def get_candidate_relations_to_delete(opts): diff --git a/django/db/models/fields/related.py b/django/db/models/fields/related.py index 22b384ad30ff..d3fe786eb286 100644 --- a/django/db/models/fields/related.py +++ b/django/db/models/fields/related.py @@ -14,7 +14,7 @@ from django.db.models.deletion import ( CASCADE, DB_CASCADE, - ON_DELETE_DB_CHOICES, + DB_SET_NULL, SET_DEFAULT, SET_NULL, ) @@ -950,7 +950,6 @@ def __init__( self, to, on_delete, - on_delete_db=ON_DELETE_DB_CHOICES.DO_NOTHING_DB, related_name=None, related_query_name=None, limit_choices_to=None, @@ -979,6 +978,7 @@ def __init__( to_field = to_field or (to._meta.pk and to._meta.pk.name) if not callable(on_delete): raise TypeError("on_delete must be callable.") + kwargs["rel"] = self.rel_class( self, to, @@ -988,7 +988,6 @@ def __init__( limit_choices_to=limit_choices_to, parent_link=parent_link, on_delete=on_delete, - on_delete_db=on_delete_db, ) kwargs.setdefault("db_index", True) @@ -1012,12 +1011,11 @@ def check(self, **kwargs): *super().check(**kwargs), *self._check_on_delete(), *self._check_unique(), - *self._check_on_delete_db(), ] def _check_on_delete(self): on_delete = getattr(self.remote_field, "on_delete", None) - if on_delete == SET_NULL and not self.null: + if on_delete in [SET_NULL, DB_SET_NULL] and not self.null: return [ checks.Error( "Field specifies on_delete=SET_NULL, but cannot be null.", @@ -1039,7 +1037,7 @@ def _check_on_delete(self): ) ] elif ( - on_delete == DB_CASCADE + on_delete in [DB_CASCADE, DB_SET_NULL] and hasattr(self.model, "_meta") and any( not parent._meta.abstract @@ -1058,7 +1056,7 @@ def _check_on_delete(self): ) ] elif ( - on_delete == DB_CASCADE + on_delete in [DB_CASCADE, DB_SET_NULL] and hasattr(self.model, "_meta") and ( any( # generic relation @@ -1080,8 +1078,17 @@ def _check_on_delete(self): id="fields.E345", ) ] - else: - return [] + elif self._has_related_models_with_db_cascading(self.model): + return [ + checks.Error( + "Using normal cascading with DB cascading referenced model is " + "prohibited", + hint="Use database level cascading for foreignkeys", + obj=self, + id="fields.E323", + ) + ] + return [] def _check_unique(self, **kwargs): return ( @@ -1120,8 +1127,8 @@ def _has_related_models_with_db_cascading(self, model): ( isinstance(rel, ForeignKey) and hasattr(rel.remote_field, "on_delete") - and rel.remote_field.on_delete == DB_CASCADE - and on_delete != DB_CASCADE + and rel.remote_field.on_delete in [DB_CASCADE, DB_SET_NULL] + and on_delete in [CASCADE, SET_NULL] ) for rel in model._meta.get_fields() ) @@ -1130,47 +1137,6 @@ def _has_related_models_with_db_cascading(self, model): return has_related_cascade_db - def _check_on_delete_db(self, **kwargs): - on_delete = getattr(self.remote_field, "on_delete", None) - on_delete_db = getattr(self.remote_field, "on_delete_db", None) - - if ( - on_delete_db != ON_DELETE_DB_CHOICES.DO_NOTHING_DB - and on_delete != DB_CASCADE - ): - return [ - checks.Error( - "The on_delete must be set to on_delete=models.DB_CASCADE to work" - " with on_delete_db", - hint="Remove the on_delete_db or set on_delete=models.DB_CASCADE", - obj=self, - id="fields.E322", - ) - ] - elif on_delete_db == ON_DELETE_DB_CHOICES.SET_NULL_DB and not self.null: - return [ - checks.Error( - "Field specifies on_delete_db=SET_NULL_DB, but cannot be null.", - hint=( - "Set null=True argument on the field, or change the " - "on_delete_db rule." - ), - obj=self, - id="fields.E324", - ) - ] - elif self._has_related_models_with_db_cascading(self.model): - return [ - checks.Error( - "Using normal cascading with DB cascading referenced model is " - "prohibited", - hint="Use database level cascading for foreignkeys", - obj=self, - id="fields.E323", - ) - ] - return [] - def deconstruct(self): name, path, args, kwargs = super().deconstruct() del kwargs["to_fields"] diff --git a/django/db/models/fields/reverse_related.py b/django/db/models/fields/reverse_related.py index acce3003a166..c74e92ba89f1 100644 --- a/django/db/models/fields/reverse_related.py +++ b/django/db/models/fields/reverse_related.py @@ -47,7 +47,6 @@ def __init__( limit_choices_to=None, parent_link=False, on_delete=None, - on_delete_db=None, ): self.field = field self.model = to @@ -56,7 +55,6 @@ def __init__( self.limit_choices_to = {} if limit_choices_to is None else limit_choices_to self.parent_link = parent_link self.on_delete = on_delete - self.on_delete_db = on_delete_db self.symmetrical = False self.multiple = True @@ -281,7 +279,6 @@ def __init__( limit_choices_to=None, parent_link=False, on_delete=None, - on_delete_db=None, ): super().__init__( field, @@ -291,7 +288,6 @@ def __init__( limit_choices_to=limit_choices_to, parent_link=parent_link, on_delete=on_delete, - on_delete_db=on_delete_db, ) self.field_name = field_name @@ -338,7 +334,6 @@ def __init__( limit_choices_to=None, parent_link=False, on_delete=None, - on_delete_db=None, ): super().__init__( field, @@ -349,7 +344,6 @@ def __init__( limit_choices_to=limit_choices_to, parent_link=parent_link, on_delete=on_delete, - on_delete_db=on_delete_db, ) self.multiple = False diff --git a/docs/releases/5.0.txt b/docs/releases/5.0.txt index 6ef22f716f93..c2fc47ea9027 100644 --- a/docs/releases/5.0.txt +++ b/docs/releases/5.0.txt @@ -125,20 +125,12 @@ sets a database-computed default value. For example:: Database level cascading support for ForeignKey field --------------------------------------------------------------- -The ``on_delete`` argument for ``ForeignKey`` now supports an extra option -``DB_CASCADE`` this will behave like ``DO_NOTHING`` in Django, but leverage -the Database level cascading options supported by SQL. +The ``on_delete`` argument for ``ForeignKey`` now supports three extra options +the new supported values of the ``on_delete`` argument can be the following: -A new optional argument named ``on_delete_db`` is also passed to the -``ForeignKey``. This will specify the type of database level cascading we are -looking for if the ``on_delete`` is set to ``DB_CASCADE``. - -Supported values of the ``on_delete_db`` argument can be the following: - -``ON_DELETE_DB_CHOICES.DO_NOTHING_DB`` : Does nothing, used as default value. -``ON_DELETE_DB_CHOICES.CASCADE_DB`` : Deletes the child on parent deletion. -``ON_DELETE_DB_CHOICES.RESTRICT_DB`` : Prevent the deletion if child exists. -``ON_DELETE_DB_CHOICES.SET_NULL_DB`` : Set the value to null in child table. +``DB_CASCADE`` : Deletes the child on parent deletion at a database level. +``DB_RESTRICT`` : Prevent the deletion if child exists at a database level. +``DB_SET_NULL`` : Set the value to null in child table at a database level. Minor features -------------- diff --git a/tests/db_cascade/models.py b/tests/db_cascade/models.py index 9cf101e46cca..4e02b2587f31 100644 --- a/tests/db_cascade/models.py +++ b/tests/db_cascade/models.py @@ -14,7 +14,6 @@ class Bar(models.Model): foo = models.ForeignKey( Foo, on_delete=models.DB_CASCADE, - on_delete_db=models.ON_DELETE_DB_CHOICES.CASCADE_DB, ) @@ -25,7 +24,6 @@ class Baz(models.Model): bar = models.ForeignKey( Bar, on_delete=models.DB_CASCADE, - on_delete_db=models.ON_DELETE_DB_CHOICES.CASCADE_DB, ) @@ -34,8 +32,7 @@ class RestrictBar(models.Model): foo = models.ForeignKey( Foo, - on_delete=models.DB_CASCADE, - on_delete_db=models.ON_DELETE_DB_CHOICES.RESTRICT_DB, + on_delete=models.DB_RESTRICT, ) @@ -44,8 +41,7 @@ class RestrictBaz(models.Model): bar = models.ForeignKey( Bar, - on_delete=models.DB_CASCADE, - on_delete_db=models.ON_DELETE_DB_CHOICES.RESTRICT_DB, + on_delete=models.DB_RESTRICT, ) @@ -54,8 +50,7 @@ class SetNullBar(models.Model): foo = models.ForeignKey( Foo, - on_delete=models.DB_CASCADE, - on_delete_db=models.ON_DELETE_DB_CHOICES.SET_NULL_DB, + on_delete=models.DB_SET_NULL, null=True, ) another_field = models.CharField(max_length=20) @@ -66,8 +61,7 @@ class SetNullBaz(models.Model): bar = models.ForeignKey( Bar, - on_delete=models.DB_CASCADE, - on_delete_db=models.ON_DELETE_DB_CHOICES.SET_NULL_DB, + on_delete=models.DB_SET_NULL, null=True, ) another_field = models.CharField(max_length=20) diff --git a/tests/db_cascade_checks/models.py b/tests/db_cascade_checks/models.py index 367086c58edc..ed30105d4f9e 100644 --- a/tests/db_cascade_checks/models.py +++ b/tests/db_cascade_checks/models.py @@ -11,5 +11,4 @@ class Bar(models.Model): foo = models.ForeignKey( Foo, on_delete=models.DB_CASCADE, - on_delete_db=models.ON_DELETE_DB_CHOICES.CASCADE_DB, ) diff --git a/tests/db_cascade_checks/tests.py b/tests/db_cascade_checks/tests.py index e226a32c2b33..ed45e848715a 100644 --- a/tests/db_cascade_checks/tests.py +++ b/tests/db_cascade_checks/tests.py @@ -8,28 +8,6 @@ class DatabaseLevelCascadeCheckTests(TestCase): - def test_system_check_on_on_delete_db_combination(self): - class MixedBar(models.Model): - foo = models.ForeignKey( - Foo, - on_delete=models.CASCADE, - on_delete_db=models.ON_DELETE_DB_CHOICES.CASCADE_DB, - ) - - mixed_bar_field = MixedBar._meta.get_field("foo") - self.assertEqual( - mixed_bar_field.check(), - [ - Error( - "The on_delete must be set to on_delete=models.DB_CASCADE to work" - " with on_delete_db", - hint="Remove the on_delete_db or set on_delete=models.DB_CASCADE", - obj=mixed_bar_field, - id="fields.E322", - ) - ], - ) - def test_system_check_on_nested_db_with_non_db_cascading(self): class BadBar(models.Model): foo = models.ForeignKey(Foo, on_delete=models.CASCADE) @@ -40,7 +18,6 @@ class Baz(models.Model): bar = models.ForeignKey( Bar, on_delete=models.CASCADE, - # on_delete_db=models.CASCADE ) baz_field = Baz._meta.get_field("bar") @@ -67,8 +44,7 @@ def test_null_condition_with_set_null_db(self): class SetNullDbNotNullModel(models.Model): foo = models.ForeignKey( Foo, - on_delete=models.DB_CASCADE, - on_delete_db=models.ON_DELETE_DB_CHOICES.SET_NULL_DB, + on_delete=models.DB_SET_NULL, ) class Meta: @@ -79,13 +55,13 @@ class Meta: field.check(), [ Error( - "Field specifies on_delete_db=SET_NULL_DB, but cannot be null.", + "Field specifies on_delete=SET_NULL, but cannot be null.", hint=( "Set null=True argument on the field, or change the " - "on_delete_db rule." + "on_delete rule." ), obj=field, - id="fields.E324", + id="fields.E320", ) ], ) @@ -95,7 +71,6 @@ class AnotherBar(Bar): another_foo = models.ForeignKey( Foo, on_delete=models.DB_CASCADE, - on_delete_db=models.ON_DELETE_DB_CHOICES.CASCADE_DB, ) class Meta: @@ -103,10 +78,7 @@ class Meta: class MultipleInheritedBar(Foo, Bar): another_foo = models.ForeignKey( - Foo, - on_delete=models.DB_CASCADE, - on_delete_db=models.ON_DELETE_DB_CHOICES.CASCADE_DB, - related_name="another_foo", + Foo, on_delete=models.DB_CASCADE, related_name="another_foo" ) field = AnotherBar._meta.get_field("another_foo") @@ -146,7 +118,6 @@ class SomeModel(models.Model): some_fk = models.ForeignKey( ContentType, on_delete=models.DB_CASCADE, - on_delete_db=models.ON_DELETE_DB_CHOICES.CASCADE_DB, related_name="ctcmnt", ) object_id = models.PositiveIntegerField() From 715b5c7eced70b940c2aef73dd3e4d0bf06ab512 Mon Sep 17 00:00:00 2001 From: Akash Kumar Sen Date: Tue, 16 May 2023 11:48:40 +0530 Subject: [PATCH 16/60] Remove extra newline --- django/db/backends/base/schema.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django/db/backends/base/schema.py b/django/db/backends/base/schema.py index cd11700db9c8..f40b78a311e0 100644 --- a/django/db/backends/base/schema.py +++ b/django/db/backends/base/schema.py @@ -118,7 +118,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) %(on_delete_db)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 From 567ed5a5403d8d6f6fe0703736b6d58a98ae2987 Mon Sep 17 00:00:00 2001 From: Akash Kumar Sen Date: Tue, 16 May 2023 18:47:21 +0530 Subject: [PATCH 17/60] cls --- docs/ref/models/fields.txt | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/docs/ref/models/fields.txt b/docs/ref/models/fields.txt index 344cc452805a..43ef25921fb7 100644 --- a/docs/ref/models/fields.txt +++ b/docs/ref/models/fields.txt @@ -1723,6 +1723,34 @@ The possible values for :attr:`~ForeignKey.on_delete` are found in integrity, this will cause an :exc:`~django.db.IntegrityError` unless you manually add an SQL ``ON DELETE`` constraint to the database field. +* .. attribute:: DB_CASCADE + + DB_CASCADE is a custom option introduced in Django that allows the database + to handle cascading deletes instead of relying on Django's emulation of the + SQL constraint ON DELETE CASCADE. With DB_CASCADE, the database engine + itself takes care of deleting related objects when a referenced object is + deleted. + + :meth:`.Model.delete` isn't called on related models, but the + :data:`~django.db.models.signals.pre_delete` and + :data:`~django.db.models.signals.post_delete` signals are not sent for any + deleted objects. + +* .. attribute:: DB_RESTRICT + + Prevent deletion of the referenced object by raising + :exc:`~django.db.IntegrityError`. this is going to call SQL constraint ON + DELETE RESTRICT on a database level while creating or altering the + ForeignKey and enforces restrictions on deleting objects that have + referenced relationships + +* .. attribute:: DB_SET_NULL + + Set the :class:`ForeignKey` null; this is only possible if + :attr:`~Field.null` is ``True``. This works on a database level instead of + using in-django options. this is going to call SQL constraint ON DELETE SET + NULL on a database level while creating or altering the ForeignKey + .. attribute:: ForeignKey.limit_choices_to Sets a limit to the available choices for this field when this field is From 92ad264e1daad53ed772c1cead568e5d202e78a4 Mon Sep 17 00:00:00 2001 From: Akash Kumar Sen Date: Tue, 16 May 2023 19:02:01 +0530 Subject: [PATCH 18/60] Adding documentation --- docs/ref/models/fields.txt | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/ref/models/fields.txt b/docs/ref/models/fields.txt index 43ef25921fb7..3649a929535a 100644 --- a/docs/ref/models/fields.txt +++ b/docs/ref/models/fields.txt @@ -1620,9 +1620,6 @@ 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 found in :mod:`django.db.models`: @@ -1725,6 +1722,8 @@ The possible values for :attr:`~ForeignKey.on_delete` are found in * .. attribute:: DB_CASCADE + .. versionadded:: 5.0 + DB_CASCADE is a custom option introduced in Django that allows the database to handle cascading deletes instead of relying on Django's emulation of the SQL constraint ON DELETE CASCADE. With DB_CASCADE, the database engine @@ -1738,6 +1737,8 @@ The possible values for :attr:`~ForeignKey.on_delete` are found in * .. attribute:: DB_RESTRICT + .. versionadded:: 5.0 + Prevent deletion of the referenced object by raising :exc:`~django.db.IntegrityError`. this is going to call SQL constraint ON DELETE RESTRICT on a database level while creating or altering the @@ -1746,6 +1747,8 @@ The possible values for :attr:`~ForeignKey.on_delete` are found in * .. attribute:: DB_SET_NULL + .. versionadded:: 5.0 + Set the :class:`ForeignKey` null; this is only possible if :attr:`~Field.null` is ``True``. This works on a database level instead of using in-django options. this is going to call SQL constraint ON DELETE SET From f31d056445a45c20190457001a7ec49fc0620508 Mon Sep 17 00:00:00 2001 From: Akash Kumar Sen Date: Wed, 17 May 2023 06:47:43 +0530 Subject: [PATCH 19/60] Add some more tests --- .../__init__.py | 0 .../models.py | 0 .../tests.py | 56 ++++++++++++++++++- .../__init__.py | 0 .../models.py | 0 .../tests.py | 0 6 files changed, 55 insertions(+), 1 deletion(-) rename tests/{db_cascade => db_level_on_delete}/__init__.py (100%) rename tests/{db_cascade => db_level_on_delete}/models.py (100%) rename tests/{db_cascade => db_level_on_delete}/tests.py (55%) rename tests/{db_cascade_checks => db_level_on_delete_checks}/__init__.py (100%) rename tests/{db_cascade_checks => db_level_on_delete_checks}/models.py (100%) rename tests/{db_cascade_checks => db_level_on_delete_checks}/tests.py (100%) diff --git a/tests/db_cascade/__init__.py b/tests/db_level_on_delete/__init__.py similarity index 100% rename from tests/db_cascade/__init__.py rename to tests/db_level_on_delete/__init__.py diff --git a/tests/db_cascade/models.py b/tests/db_level_on_delete/models.py similarity index 100% rename from tests/db_cascade/models.py rename to tests/db_level_on_delete/models.py diff --git a/tests/db_cascade/tests.py b/tests/db_level_on_delete/tests.py similarity index 55% rename from tests/db_cascade/tests.py rename to tests/db_level_on_delete/tests.py index 2e79424b5fe5..3d3c75ec99e8 100644 --- a/tests/db_cascade/tests.py +++ b/tests/db_level_on_delete/tests.py @@ -4,7 +4,7 @@ from .models import Bar, Baz, Foo, RestrictBar, RestrictBaz, SetNullBar, SetNullBaz -class DatabaseLevelCascadeTests(TestCase): +class DatabaseLevelOnDeleteTests(TestCase): def test_deletion_on_nested_cascades(self): foo = Foo.objects.create() bar = Bar.objects.create(foo=foo) @@ -55,3 +55,57 @@ def test_set_null_on_cascade_deletion(self): self.assertEqual(baz.another_field, orphan_baz.another_field) self.assertNotEqual(baz.bar, orphan_baz.bar) self.assertIsNone(orphan_baz.bar) + + +class DatabaseLevelOnDeleteQueryAssertionTests(TestCase): + def test_queries_on_nested_cascade(self): + foo = Foo.objects.create() + + for i in range(3): + Bar.objects.create(foo=foo) + + for bar in Bar.objects.all(): + for i in range(3): + Baz.objects.create(bar=bar) + + # one is the deletion + # three select queries for Bar, SetNullBar and RestrictBar + with self.assertNumQueries(4): + foo.delete() + + def test_queries_on_nested_set_null(self): + foo = Foo.objects.create() + + for i in range(3): + SetNullBar.objects.create(foo=foo) + + for bar in Bar.objects.all(): + for i in range(3): + SetNullBaz.objects.create(bar=bar) + + # one is the deletion + # three select queries for Bar, SetNullBar and RestrictBar + with self.assertNumQueries(4): + foo.delete() + + def test_queries_together_on_nested_set_null_cascade(self): + foo = Foo.objects.create() + + for i in range(3): + Bar.objects.create(foo=foo) + + for bar in Bar.objects.all(): + for i in range(3): + Baz.objects.create(bar=bar) + + for i in range(3): + SetNullBar.objects.create(foo=foo) + + for bar in Bar.objects.all(): + for i in range(3): + SetNullBaz.objects.create(bar=bar) + + # one is the deletion + # three select queries for Bar, SetNullBar and RestrictBar + with self.assertNumQueries(4): + foo.delete() diff --git a/tests/db_cascade_checks/__init__.py b/tests/db_level_on_delete_checks/__init__.py similarity index 100% rename from tests/db_cascade_checks/__init__.py rename to tests/db_level_on_delete_checks/__init__.py diff --git a/tests/db_cascade_checks/models.py b/tests/db_level_on_delete_checks/models.py similarity index 100% rename from tests/db_cascade_checks/models.py rename to tests/db_level_on_delete_checks/models.py diff --git a/tests/db_cascade_checks/tests.py b/tests/db_level_on_delete_checks/tests.py similarity index 100% rename from tests/db_cascade_checks/tests.py rename to tests/db_level_on_delete_checks/tests.py From 015f1efb21e4b87e3caff73440695a7b55bc2b5d Mon Sep 17 00:00:00 2001 From: Akash Kumar Sen Date: Wed, 17 May 2023 07:36:21 +0530 Subject: [PATCH 20/60] Add more tests --- tests/db_level_on_delete/models.py | 11 ++++++ tests/db_level_on_delete/tests.py | 55 ++++++++++++++++-------------- 2 files changed, 41 insertions(+), 25 deletions(-) diff --git a/tests/db_level_on_delete/models.py b/tests/db_level_on_delete/models.py index 4e02b2587f31..5085b7bd3a3e 100644 --- a/tests/db_level_on_delete/models.py +++ b/tests/db_level_on_delete/models.py @@ -65,3 +65,14 @@ class SetNullBaz(models.Model): null=True, ) another_field = models.CharField(max_length=20) + + +class AnotherSetNullBaz(models.Model): + """Second level child of foo with cascading set to null""" + + setnullbar = models.ForeignKey( + SetNullBar, + on_delete=models.DB_SET_NULL, + null=True, + ) + another_field = models.CharField(max_length=20) diff --git a/tests/db_level_on_delete/tests.py b/tests/db_level_on_delete/tests.py index 3d3c75ec99e8..be5f58c160e0 100644 --- a/tests/db_level_on_delete/tests.py +++ b/tests/db_level_on_delete/tests.py @@ -1,7 +1,16 @@ from django.db import IntegrityError from django.test import TestCase -from .models import Bar, Baz, Foo, RestrictBar, RestrictBaz, SetNullBar, SetNullBaz +from .models import ( + AnotherSetNullBaz, + Bar, + Baz, + Foo, + RestrictBar, + RestrictBaz, + SetNullBar, + SetNullBaz, +) class DatabaseLevelOnDeleteTests(TestCase): @@ -56,6 +65,24 @@ def test_set_null_on_cascade_deletion(self): self.assertNotEqual(baz.bar, orphan_baz.bar) self.assertIsNone(orphan_baz.bar) + def test_nested_set_null_on_deletion(self): + foo = Foo.objects.create() + bar = SetNullBar.objects.create(foo=foo) + baz = AnotherSetNullBaz.objects.create(setnullbar=bar) + foo.delete() + + orphan_bar = SetNullBar.objects.get(pk=bar.pk) + self.assertEqual(bar.pk, orphan_bar.pk) + self.assertEqual(bar.another_field, orphan_bar.another_field) + self.assertNotEqual(bar.foo, orphan_bar.foo) + self.assertIsNone(orphan_bar.foo) + + orphan_baz = AnotherSetNullBaz.objects.get(pk=baz.pk) + self.assertEqual(baz.pk, orphan_baz.pk) + self.assertEqual(baz.another_field, orphan_baz.another_field) + self.assertEqual(baz.setnullbar, orphan_baz.setnullbar) + self.assertIsNotNone(orphan_baz.setnullbar) + class DatabaseLevelOnDeleteQueryAssertionTests(TestCase): def test_queries_on_nested_cascade(self): @@ -79,31 +106,9 @@ def test_queries_on_nested_set_null(self): for i in range(3): SetNullBar.objects.create(foo=foo) - for bar in Bar.objects.all(): - for i in range(3): - SetNullBaz.objects.create(bar=bar) - - # one is the deletion - # three select queries for Bar, SetNullBar and RestrictBar - with self.assertNumQueries(4): - foo.delete() - - def test_queries_together_on_nested_set_null_cascade(self): - foo = Foo.objects.create() - - for i in range(3): - Bar.objects.create(foo=foo) - - for bar in Bar.objects.all(): - for i in range(3): - Baz.objects.create(bar=bar) - - for i in range(3): - SetNullBar.objects.create(foo=foo) - - for bar in Bar.objects.all(): + for setnullbar in SetNullBar.objects.all(): for i in range(3): - SetNullBaz.objects.create(bar=bar) + AnotherSetNullBaz.objects.create(setnullbar=setnullbar) # one is the deletion # three select queries for Bar, SetNullBar and RestrictBar From 293e4d5e2274f8829d73d24773c46c248c2fc05a Mon Sep 17 00:00:00 2001 From: Akash Kumar Sen Date: Wed, 17 May 2023 09:26:30 +0530 Subject: [PATCH 21/60] Adjust testcases --- tests/db_level_on_delete/models.py | 11 ---------- tests/db_level_on_delete/tests.py | 35 ++++-------------------------- 2 files changed, 4 insertions(+), 42 deletions(-) diff --git a/tests/db_level_on_delete/models.py b/tests/db_level_on_delete/models.py index 5085b7bd3a3e..4e02b2587f31 100644 --- a/tests/db_level_on_delete/models.py +++ b/tests/db_level_on_delete/models.py @@ -65,14 +65,3 @@ class SetNullBaz(models.Model): null=True, ) another_field = models.CharField(max_length=20) - - -class AnotherSetNullBaz(models.Model): - """Second level child of foo with cascading set to null""" - - setnullbar = models.ForeignKey( - SetNullBar, - on_delete=models.DB_SET_NULL, - null=True, - ) - another_field = models.CharField(max_length=20) diff --git a/tests/db_level_on_delete/tests.py b/tests/db_level_on_delete/tests.py index be5f58c160e0..d862df750c4e 100644 --- a/tests/db_level_on_delete/tests.py +++ b/tests/db_level_on_delete/tests.py @@ -1,16 +1,7 @@ from django.db import IntegrityError from django.test import TestCase -from .models import ( - AnotherSetNullBaz, - Bar, - Baz, - Foo, - RestrictBar, - RestrictBaz, - SetNullBar, - SetNullBaz, -) +from .models import Bar, Baz, Foo, RestrictBar, RestrictBaz, SetNullBar, SetNullBaz class DatabaseLevelOnDeleteTests(TestCase): @@ -65,24 +56,6 @@ def test_set_null_on_cascade_deletion(self): self.assertNotEqual(baz.bar, orphan_baz.bar) self.assertIsNone(orphan_baz.bar) - def test_nested_set_null_on_deletion(self): - foo = Foo.objects.create() - bar = SetNullBar.objects.create(foo=foo) - baz = AnotherSetNullBaz.objects.create(setnullbar=bar) - foo.delete() - - orphan_bar = SetNullBar.objects.get(pk=bar.pk) - self.assertEqual(bar.pk, orphan_bar.pk) - self.assertEqual(bar.another_field, orphan_bar.another_field) - self.assertNotEqual(bar.foo, orphan_bar.foo) - self.assertIsNone(orphan_bar.foo) - - orphan_baz = AnotherSetNullBaz.objects.get(pk=baz.pk) - self.assertEqual(baz.pk, orphan_baz.pk) - self.assertEqual(baz.another_field, orphan_baz.another_field) - self.assertEqual(baz.setnullbar, orphan_baz.setnullbar) - self.assertIsNotNone(orphan_baz.setnullbar) - class DatabaseLevelOnDeleteQueryAssertionTests(TestCase): def test_queries_on_nested_cascade(self): @@ -104,11 +77,11 @@ def test_queries_on_nested_set_null(self): foo = Foo.objects.create() for i in range(3): - SetNullBar.objects.create(foo=foo) + Bar.objects.create(foo=foo) - for setnullbar in SetNullBar.objects.all(): + for bar in Bar.objects.all(): for i in range(3): - AnotherSetNullBaz.objects.create(setnullbar=setnullbar) + SetNullBaz.objects.create(bar=bar) # one is the deletion # three select queries for Bar, SetNullBar and RestrictBar From ae5a26effde2a3a9e283e207398d177a6f1cb6ba Mon Sep 17 00:00:00 2001 From: Akash Kumar Sen Date: Wed, 17 May 2023 10:15:47 +0530 Subject: [PATCH 22/60] Fix typo in one test --- tests/db_level_on_delete/models.py | 11 +++++++ tests/db_level_on_delete/tests.py | 46 ++++++++++++++++++++++++++++-- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/tests/db_level_on_delete/models.py b/tests/db_level_on_delete/models.py index 4e02b2587f31..5085b7bd3a3e 100644 --- a/tests/db_level_on_delete/models.py +++ b/tests/db_level_on_delete/models.py @@ -65,3 +65,14 @@ class SetNullBaz(models.Model): null=True, ) another_field = models.CharField(max_length=20) + + +class AnotherSetNullBaz(models.Model): + """Second level child of foo with cascading set to null""" + + setnullbar = models.ForeignKey( + SetNullBar, + on_delete=models.DB_SET_NULL, + null=True, + ) + another_field = models.CharField(max_length=20) diff --git a/tests/db_level_on_delete/tests.py b/tests/db_level_on_delete/tests.py index d862df750c4e..395bb82572a6 100644 --- a/tests/db_level_on_delete/tests.py +++ b/tests/db_level_on_delete/tests.py @@ -1,7 +1,16 @@ from django.db import IntegrityError from django.test import TestCase -from .models import Bar, Baz, Foo, RestrictBar, RestrictBaz, SetNullBar, SetNullBaz +from .models import ( + AnotherSetNullBaz, + Bar, + Baz, + Foo, + RestrictBar, + RestrictBaz, + SetNullBar, + SetNullBaz, +) class DatabaseLevelOnDeleteTests(TestCase): @@ -11,7 +20,7 @@ def test_deletion_on_nested_cascades(self): baz = Baz.objects.create(bar=bar) self.assertEqual(bar, Bar.objects.get(pk=bar.pk)) - self.assertEqual(baz, Baz.objects.get(pk=bar.pk)) + self.assertEqual(baz, Baz.objects.get(pk=baz.pk)) foo.delete() @@ -56,6 +65,24 @@ def test_set_null_on_cascade_deletion(self): self.assertNotEqual(baz.bar, orphan_baz.bar) self.assertIsNone(orphan_baz.bar) + def test_nested_set_null_on_deletion(self): + foo = Foo.objects.create() + bar = SetNullBar.objects.create(foo=foo) + baz = AnotherSetNullBaz.objects.create(setnullbar=bar) + foo.delete() + + orphan_bar = SetNullBar.objects.get(pk=bar.pk) + self.assertEqual(bar.pk, orphan_bar.pk) + self.assertEqual(bar.another_field, orphan_bar.another_field) + self.assertNotEqual(bar.foo, orphan_bar.foo) + self.assertIsNone(orphan_bar.foo) + + orphan_baz = AnotherSetNullBaz.objects.get(pk=baz.pk) + self.assertEqual(baz.pk, orphan_baz.pk) + self.assertEqual(baz.another_field, orphan_baz.another_field) + self.assertEqual(baz.setnullbar, orphan_baz.setnullbar) + self.assertIsNotNone(orphan_baz.setnullbar) + class DatabaseLevelOnDeleteQueryAssertionTests(TestCase): def test_queries_on_nested_cascade(self): @@ -76,6 +103,21 @@ def test_queries_on_nested_cascade(self): def test_queries_on_nested_set_null(self): foo = Foo.objects.create() + for i in range(3): + SetNullBar.objects.create(foo=foo) + + for setnullbar in SetNullBar.objects.all(): + for i in range(3): + AnotherSetNullBaz.objects.create(setnullbar=setnullbar) + + # one is the deletion + # three select queries for Bar, SetNullBar and RestrictBar + with self.assertNumQueries(4): + foo.delete() + + def test_queries_on_nested_set_null_cascade(self): + foo = Foo.objects.create() + for i in range(3): Bar.objects.create(foo=foo) From ad202efd92b6580f3a3e349d1d8788174c341ed3 Mon Sep 17 00:00:00 2001 From: Akash Kumar Sen Date: Thu, 18 May 2023 11:46:32 +0530 Subject: [PATCH 23/60] Docs changes suggested and Co-authored-by: Adam Johnson(@adamchainz) Signed-off-by: Akash Kumar Sen --- docs/ref/models/fields.txt | 41 +++++++++++++++++++++----------------- docs/releases/5.0.txt | 18 +++++++++-------- 2 files changed, 33 insertions(+), 26 deletions(-) diff --git a/docs/ref/models/fields.txt b/docs/ref/models/fields.txt index 3649a929535a..9916adb18e7f 100644 --- a/docs/ref/models/fields.txt +++ b/docs/ref/models/fields.txt @@ -1620,8 +1620,12 @@ relation works. null=True, ) -The possible values for :attr:`~ForeignKey.on_delete` are found in -:mod:`django.db.models`: +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 normally more +performant because they require less data to be fetched, but they don’t call +any custom methods or send any signals. * .. attribute:: CASCADE @@ -1724,35 +1728,36 @@ The possible values for :attr:`~ForeignKey.on_delete` are found in .. versionadded:: 5.0 - DB_CASCADE is a custom option introduced in Django that allows the database - to handle cascading deletes instead of relying on Django's emulation of the - SQL constraint ON DELETE CASCADE. With DB_CASCADE, the database engine - itself takes care of deleting related objects when a referenced object is - deleted. + ``DB_CASCADE`` is a custom option introduced in Django that allows the + database to handle cascading deletes instead of relying on Django's + emulation of the SQL constraint ``ON DELETE CASCADE``. With ``DB_CASCADE``, + the database itself takes care of deleting related objects when a + referenced object is deleted. - :meth:`.Model.delete` isn't called on related models, but the + :meth:`.Model.delete` isn't called on related models, and the :data:`~django.db.models.signals.pre_delete` and - :data:`~django.db.models.signals.post_delete` signals are not sent for any - deleted objects. + :data:`~django.db.models.signals.post_delete` signals are also not sent for + any deleted objects. * .. attribute:: DB_RESTRICT .. versionadded:: 5.0 - Prevent deletion of the referenced object by raising - :exc:`~django.db.IntegrityError`. this is going to call SQL constraint ON - DELETE RESTRICT on a database level while creating or altering the - ForeignKey and enforces restrictions on deleting objects that have - referenced relationships + It prevents the deletion of the referenced object by raising the + :class:`~django.db.IntegrityError` exception. This action triggers the SQL + constraint ``ON DELETE RESTRICT`` at the database level during the creation + or modification of the ``ForeignKey``. It enforces restrictions on deleting + objects that have referenced relationships. * .. attribute:: DB_SET_NULL .. versionadded:: 5.0 - Set the :class:`ForeignKey` null; this is only possible if + Set the :class:`ForeignKey` value to ``null``; this is only possible if :attr:`~Field.null` is ``True``. This works on a database level instead of - using in-django options. this is going to call SQL constraint ON DELETE SET - NULL on a database level while creating or altering the ForeignKey + using in-django options. This triggers SQL constraint + ``ON DELETE SET NULL`` on a database level while creating or altering the + ``ForeignKey``. .. attribute:: ForeignKey.limit_choices_to diff --git a/docs/releases/5.0.txt b/docs/releases/5.0.txt index c2fc47ea9027..c30a9cc5c9ae 100644 --- a/docs/releases/5.0.txt +++ b/docs/releases/5.0.txt @@ -123,14 +123,16 @@ sets a database-computed default value. For example:: created = models.DateTimeField(db_default=Now()) circumference = models.FloatField(db_default=2 * Pi()) -Database level cascading support for ForeignKey field ---------------------------------------------------------------- -The ``on_delete`` argument for ``ForeignKey`` now supports three extra options -the new supported values of the ``on_delete`` argument can be the following: - -``DB_CASCADE`` : Deletes the child on parent deletion at a database level. -``DB_RESTRICT`` : Prevent the deletion if child exists at a database level. -``DB_SET_NULL`` : Set the value to null in child table at a database level. +Database-level values for ``ForeignKey.on_delete`` +-------------------------------------------------- + +:class:`~django.db.models.ForeignKey` +:attr:`~django.db.models.ForeignKey.on_delete` now supports three extra values +to specify database-level actions: + +* ``DB_CASCADE`` - deletes the referring object. +* ``DB_RESTRICT`` - prevents deletion of referred-to objects. +* ``DB_SET_NULL`` - sets the referring foreign key to SQL ``NULL``. Minor features -------------- From 8b518c8c01254c393ea0a23df055109936a572b3 Mon Sep 17 00:00:00 2001 From: Akash Kumar Sen Date: Thu, 18 May 2023 12:00:19 +0530 Subject: [PATCH 24/60] Fix spelling --- docs/ref/models/fields.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ref/models/fields.txt b/docs/ref/models/fields.txt index 9916adb18e7f..de90c71e9858 100644 --- a/docs/ref/models/fields.txt +++ b/docs/ref/models/fields.txt @@ -1624,7 +1624,7 @@ 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 normally more -performant because they require less data to be fetched, but they don’t call +efficient because they require less data to be fetched, but they don’t call any custom methods or send any signals. * .. attribute:: CASCADE From dc82510b75f7fe18116602f4c9eefe2e8354a5e6 Mon Sep 17 00:00:00 2001 From: Akash Kumar Sen Date: Thu, 18 May 2023 13:34:36 +0530 Subject: [PATCH 25/60] Update older docs --- docs/ref/models/fields.txt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/ref/models/fields.txt b/docs/ref/models/fields.txt index de90c71e9858..078689686b88 100644 --- a/docs/ref/models/fields.txt +++ b/docs/ref/models/fields.txt @@ -1607,11 +1607,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, From ecc524c7d89f0965f8c03fd44ca657e04f73aef0 Mon Sep 17 00:00:00 2001 From: Akash Kumar Sen Date: Fri, 2 Jun 2023 10:00:54 +0530 Subject: [PATCH 26/60] Optimize delete query, add check for inherited models --- django/db/backends/oracle/schema.py | 2 +- django/db/models/deletion.py | 22 ++++-- django/db/models/fields/related.py | 89 +++++++++++++----------- tests/db_level_on_delete/models.py | 9 ++- tests/db_level_on_delete/tests.py | 27 ++++--- tests/db_level_on_delete_checks/tests.py | 56 ++------------- 6 files changed, 96 insertions(+), 109 deletions(-) diff --git a/django/db/backends/oracle/schema.py b/django/db/backends/oracle/schema.py index c163a636a439..f1a7ba2d3f24 100644 --- a/django/db/backends/oracle/schema.py +++ b/django/db/backends/oracle/schema.py @@ -21,7 +21,7 @@ class DatabaseSchemaEditor(BaseDatabaseSchemaEditor): sql_delete_column = "ALTER TABLE %(table)s DROP COLUMN %(column)s" sql_create_column_inline_fk = ( - "CONSTRAINT %(name)s REFERENCES %(to_table)s(%(to_column)s)%(on_delete_db)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" diff --git a/django/db/models/deletion.py b/django/db/models/deletion.py index 10bf57f02af0..53a561a3ae06 100644 --- a/django/db/models/deletion.py +++ b/django/db/models/deletion.py @@ -84,8 +84,9 @@ def DO_NOTHING(collector, field, sub_objs, using): class DatabaseOnDelete: - def __init__(self, operation): + def __init__(self, operation, name): self.operation = operation + self.name = name def __call__(self, collector, field, sub_objs, using): pass @@ -93,10 +94,13 @@ def __call__(self, collector, field, sub_objs, using): def as_sql(self, connection): return connection.ops.fk_on_delete_sql(self.operation) + def __str__(self): + return self.name -DB_CASCADE = DatabaseOnDelete("CASCADE") -DB_SET_NULL = DatabaseOnDelete("SET NULL") -DB_RESTRICT = DatabaseOnDelete("RESTRICT") + +DB_CASCADE = DatabaseOnDelete("CASCADE", "DB_CASCADE") +DB_SET_NULL = DatabaseOnDelete("SET NULL", "DB_SET_NULL") +DB_RESTRICT = DatabaseOnDelete("RESTRICT", "DB_RESTRICT") def get_candidate_relations_to_delete(opts): @@ -232,7 +236,13 @@ 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 [ + DO_NOTHING, + DB_CASCADE, + DB_SET_NULL, + DB_RESTRICT, + ] for related in get_candidate_relations_to_delete(opts) ) and ( @@ -333,7 +343,7 @@ def collect( continue field = related.field on_delete = field.remote_field.on_delete - if on_delete == DO_NOTHING: + if on_delete in [DO_NOTHING, DB_CASCADE, DB_SET_NULL, DB_RESTRICT]: continue related_model = related.related_model if self.can_fast_delete(related_model, from_field=field): diff --git a/django/db/models/fields/related.py b/django/db/models/fields/related.py index d3fe786eb286..f1b2d695a28f 100644 --- a/django/db/models/fields/related.py +++ b/django/db/models/fields/related.py @@ -14,6 +14,7 @@ from django.db.models.deletion import ( CASCADE, DB_CASCADE, + DB_RESTRICT, DB_SET_NULL, SET_DEFAULT, SET_NULL, @@ -1015,10 +1016,10 @@ def check(self, **kwargs): def _check_on_delete(self): on_delete = getattr(self.remote_field, "on_delete", None) - if on_delete in [SET_NULL, DB_SET_NULL] and not self.null: + if on_delete == DB_SET_NULL and not self.null: return [ checks.Error( - "Field specifies on_delete=SET_NULL, but cannot be null.", + "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." @@ -1027,36 +1028,29 @@ def _check_on_delete(self): id="fields.E320", ) ] - elif on_delete == SET_DEFAULT and not self.has_default(): + elif on_delete == SET_NULL and not self.null: return [ checks.Error( - "Field specifies on_delete=SET_DEFAULT, but has no default value.", - hint="Set a default value, or change the on_delete rule.", + "Field specifies on_delete=SET_NULL, but cannot be null.", + hint=( + "Set null=True argument on the field, or change the on_delete " + "rule." + ), obj=self, - id="fields.E321", + id="fields.E320", ) ] - elif ( - on_delete in [DB_CASCADE, DB_SET_NULL] - and hasattr(self.model, "_meta") - and any( - not parent._meta.abstract - for parent in self.model._meta.get_parent_list() - ) - ): + elif on_delete == SET_DEFAULT and not self.has_default(): return [ checks.Error( - "Field specifies unsupported on_delete=DB_CASCADE, on " - "inherited model as it already contains a parent_ptr " - "with in-python cascading options, both cannot be used " - "together", - hint="Change the on_delete rule to other options", + "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.E325", + id="fields.E321", ) ] elif ( - on_delete in [DB_CASCADE, DB_SET_NULL] + on_delete in [DB_CASCADE, DB_SET_NULL, DB_RESTRICT] and hasattr(self.model, "_meta") and ( any( # generic relation @@ -1071,18 +1065,22 @@ def _check_on_delete(self): ): return [ checks.Error( - "Field specifies unsupported on_delete=DB_CASCADE on model " + f"Field specifies unsupported on_delete={on_delete} on model " "declaring a GenericForeignKey.", - hint="Change the on_delete rule.", + hint="Change the on_delete rule to a non DB_* method", obj=self, id="fields.E345", ) ] - elif self._has_related_models_with_db_cascading(self.model): + elif related_model_status := self._has_related_models_with_db_cascading( + self.model + ): return [ checks.Error( "Using normal cascading with DB cascading referenced model is " - "prohibited", + "prohibited " + f"Related model is {related_model_status.get('model')} " + f"Related field is {related_model_status.get('field')}", hint="Use database level cascading for foreignkeys", obj=self, id="fields.E323", @@ -1113,29 +1111,40 @@ def _has_related_models_with_db_cascading(self, model): If the foreignkey parent has DB cascading and the Current model has non db cascading return true """ - if not hasattr(model, "_meta"): - return False - + non_db_related_models = {} on_delete = getattr(self.remote_field, "on_delete", None) + # Optimization for the case when the model does not have non-db deletion + if not hasattr(model, "_meta") or on_delete in [ + DB_CASCADE, + DB_SET_NULL, + DB_RESTRICT, + ]: + return non_db_related_models + # Fetch all the models related to the current model + # In other words fetch all the foreignkey childs. related_models = [ rel.related_model for rel in model._meta.get_fields() - if rel.related_model and not rel.auto_created + if rel.related_model + and not rel.auto_created + and hasattr(rel.related_model, "_meta") ] - has_related_cascade_db = any( - any( - ( + for rel_model in related_models: + # check through the related models + # if they have DB level deletion return True + # Our current model already has non_db cascade + for rel in rel_model._meta.get_fields(): + if ( isinstance(rel, ForeignKey) and hasattr(rel.remote_field, "on_delete") - and rel.remote_field.on_delete in [DB_CASCADE, DB_SET_NULL] - and on_delete in [CASCADE, SET_NULL] - ) - for rel in model._meta.get_fields() - ) - for model in [item for item in related_models if hasattr(item, "_meta")] - ) + and rel.remote_field.on_delete + in [DB_CASCADE, DB_SET_NULL, DB_RESTRICT] + ): + non_db_related_models["model"] = rel_model + non_db_related_models["field"] = rel + return non_db_related_models - return has_related_cascade_db + return non_db_related_models def deconstruct(self): name, path, args, kwargs = super().deconstruct() diff --git a/tests/db_level_on_delete/models.py b/tests/db_level_on_delete/models.py index 5085b7bd3a3e..d66434869fb9 100644 --- a/tests/db_level_on_delete/models.py +++ b/tests/db_level_on_delete/models.py @@ -4,7 +4,14 @@ class Foo(models.Model): """Initial model named Foo""" - pass + +class ChildFoo(Foo): + foo_ptr = models.OneToOneField( + Foo, + on_delete=models.DB_CASCADE, + parent_link=True, + primary_key=True, + ) class Bar(models.Model): diff --git a/tests/db_level_on_delete/tests.py b/tests/db_level_on_delete/tests.py index 395bb82572a6..c8314b5e8d8f 100644 --- a/tests/db_level_on_delete/tests.py +++ b/tests/db_level_on_delete/tests.py @@ -5,6 +5,7 @@ AnotherSetNullBaz, Bar, Baz, + ChildFoo, Foo, RestrictBar, RestrictBaz, @@ -19,16 +20,13 @@ def test_deletion_on_nested_cascades(self): bar = Bar.objects.create(foo=foo) baz = Baz.objects.create(bar=bar) - self.assertEqual(bar, Bar.objects.get(pk=bar.pk)) - self.assertEqual(baz, Baz.objects.get(pk=baz.pk)) - foo.delete() with self.assertRaises(Bar.DoesNotExist): - Bar.objects.get(pk=bar.pk) + bar.refresh_from_db() with self.assertRaises(Baz.DoesNotExist): - Baz.objects.get(pk=baz.pk) + baz.refresh_from_db() def test_restricted_deletion(self): foo = Foo.objects.create() @@ -96,8 +94,7 @@ def test_queries_on_nested_cascade(self): Baz.objects.create(bar=bar) # one is the deletion - # three select queries for Bar, SetNullBar and RestrictBar - with self.assertNumQueries(4): + with self.assertNumQueries(1): foo.delete() def test_queries_on_nested_set_null(self): @@ -111,8 +108,7 @@ def test_queries_on_nested_set_null(self): AnotherSetNullBaz.objects.create(setnullbar=setnullbar) # one is the deletion - # three select queries for Bar, SetNullBar and RestrictBar - with self.assertNumQueries(4): + with self.assertNumQueries(1): foo.delete() def test_queries_on_nested_set_null_cascade(self): @@ -126,6 +122,15 @@ def test_queries_on_nested_set_null_cascade(self): SetNullBaz.objects.create(bar=bar) # one is the deletion - # three select queries for Bar, SetNullBar and RestrictBar - with self.assertNumQueries(4): + with self.assertNumQueries(1): foo.delete() + + def test_deletion_on_inherited_model(self): + foo1 = Foo.objects.create() + child_foo = ChildFoo.objects.create(foo_ptr=foo1) + + with self.assertNumQueries(1): + foo1.delete() + + with self.assertRaises(ChildFoo.DoesNotExist): + child_foo.refresh_from_db() diff --git a/tests/db_level_on_delete_checks/tests.py b/tests/db_level_on_delete_checks/tests.py index ed45e848715a..bd29a0de13bd 100644 --- a/tests/db_level_on_delete_checks/tests.py +++ b/tests/db_level_on_delete_checks/tests.py @@ -21,12 +21,15 @@ class Baz(models.Model): ) baz_field = Baz._meta.get_field("bar") + related_model_status = {"model": Bar, "field": Bar._meta.get_field("foo")} self.assertEqual( baz_field.check(), [ Error( "Using normal cascading with DB cascading referenced model is " - "prohibited", + "prohibited " + f"Related model is {related_model_status.get('model')} " + f"Related field is {related_model_status.get('field')}", hint="Use database level cascading for foreignkeys", obj=baz_field, id="fields.E323", @@ -55,7 +58,7 @@ class Meta: field.check(), [ Error( - "Field specifies on_delete=SET_NULL, but cannot be null.", + "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." @@ -66,53 +69,6 @@ class Meta: ], ) - def test_check_on_inherited_models(self): - class AnotherBar(Bar): - another_foo = models.ForeignKey( - Foo, - on_delete=models.DB_CASCADE, - ) - - class Meta: - managed = False - - class MultipleInheritedBar(Foo, Bar): - another_foo = models.ForeignKey( - Foo, on_delete=models.DB_CASCADE, related_name="another_foo" - ) - - field = AnotherBar._meta.get_field("another_foo") - self.assertEqual( - field.check(), - [ - Error( - "Field specifies unsupported on_delete=DB_CASCADE, on " - "inherited model as it already contains a parent_ptr " - "with in-python cascading options, both cannot be used " - "together", - hint="Change the on_delete rule to other options", - obj=field, - id="fields.E325", - ) - ], - ) - - multiple_inheritence_field = MultipleInheritedBar._meta.get_field("another_foo") - self.assertEqual( - multiple_inheritence_field.check(), - [ - Error( - "Field specifies unsupported on_delete=DB_CASCADE, on " - "inherited model as it already contains a parent_ptr " - "with in-python cascading options, both cannot be used " - "together", - hint="Change the on_delete rule to other options", - obj=multiple_inheritence_field, - id="fields.E325", - ) - ], - ) - def test_check_on_generic_foreign_key(self): class SomeModel(models.Model): some_fk = models.ForeignKey( @@ -143,7 +99,7 @@ class Meta: Error( "Field specifies unsupported on_delete=DB_CASCADE on model " "declaring a GenericForeignKey.", - hint="Change the on_delete rule.", + hint="Change the on_delete rule to a non DB_* method", obj=comment_field, id="fields.E345", ) From 3a4ae1197a4b4e246c0860921f80861d8a84c3ab Mon Sep 17 00:00:00 2001 From: Akash Kumar Sen Date: Fri, 2 Jun 2023 12:25:38 +0530 Subject: [PATCH 27/60] Cleanup --- django/db/models/fields/related.py | 17 +++++++++ tests/db_level_on_delete/models.py | 9 ----- tests/db_level_on_delete/tests.py | 11 ------ tests/db_level_on_delete_checks/models.py | 2 -- tests/db_level_on_delete_checks/tests.py | 43 +++++++++++++++++++++++ 5 files changed, 60 insertions(+), 22 deletions(-) diff --git a/django/db/models/fields/related.py b/django/db/models/fields/related.py index f1b2d695a28f..f2ddadd0ba87 100644 --- a/django/db/models/fields/related.py +++ b/django/db/models/fields/related.py @@ -1086,6 +1086,23 @@ def _check_on_delete(self): id="fields.E323", ) ] + elif ( + on_delete in [DB_CASCADE, DB_SET_NULL, DB_RESTRICT] + and hasattr(self.model, "_meta") + and any( + not parent._meta.abstract + for parent in self.model._meta.get_parent_list() + ) + ): + return [ + checks.Error( + f"Field specifies unsupported on_delete={on_delete} on " + "inherited model. Use non DB_* values for this field", + hint="Change the on_delete rule to other options", + obj=self, + id="fields.E325", + ) + ] return [] def _check_unique(self, **kwargs): diff --git a/tests/db_level_on_delete/models.py b/tests/db_level_on_delete/models.py index d66434869fb9..78a224cb5089 100644 --- a/tests/db_level_on_delete/models.py +++ b/tests/db_level_on_delete/models.py @@ -5,15 +5,6 @@ class Foo(models.Model): """Initial model named Foo""" -class ChildFoo(Foo): - foo_ptr = models.OneToOneField( - Foo, - on_delete=models.DB_CASCADE, - parent_link=True, - primary_key=True, - ) - - class Bar(models.Model): """First level foreignkey child for Foo Implemented using database level cascading""" diff --git a/tests/db_level_on_delete/tests.py b/tests/db_level_on_delete/tests.py index c8314b5e8d8f..5090fb3dd999 100644 --- a/tests/db_level_on_delete/tests.py +++ b/tests/db_level_on_delete/tests.py @@ -5,7 +5,6 @@ AnotherSetNullBaz, Bar, Baz, - ChildFoo, Foo, RestrictBar, RestrictBaz, @@ -124,13 +123,3 @@ def test_queries_on_nested_set_null_cascade(self): # one is the deletion with self.assertNumQueries(1): foo.delete() - - def test_deletion_on_inherited_model(self): - foo1 = Foo.objects.create() - child_foo = ChildFoo.objects.create(foo_ptr=foo1) - - with self.assertNumQueries(1): - foo1.delete() - - with self.assertRaises(ChildFoo.DoesNotExist): - child_foo.refresh_from_db() diff --git a/tests/db_level_on_delete_checks/models.py b/tests/db_level_on_delete_checks/models.py index ed30105d4f9e..222a1fefc00c 100644 --- a/tests/db_level_on_delete_checks/models.py +++ b/tests/db_level_on_delete_checks/models.py @@ -4,8 +4,6 @@ class Foo(models.Model): """Initial model named Foo""" - pass - class Bar(models.Model): foo = models.ForeignKey( diff --git a/tests/db_level_on_delete_checks/tests.py b/tests/db_level_on_delete_checks/tests.py index bd29a0de13bd..505eb98c2f45 100644 --- a/tests/db_level_on_delete_checks/tests.py +++ b/tests/db_level_on_delete_checks/tests.py @@ -69,6 +69,49 @@ class Meta: ], ) + def test_check_on_inherited_models(self): + class AnotherBar(Bar): + another_foo = models.ForeignKey( + Foo, + on_delete=models.DB_CASCADE, + ) + + class Meta: + managed = False + + class MultipleInheritedBar(Foo, Bar): + another_foo = models.ForeignKey( + Foo, on_delete=models.DB_CASCADE, related_name="another_foo" + ) + + field = AnotherBar._meta.get_field("another_foo") + self.assertEqual( + field.check(), + [ + Error( + "Field specifies unsupported on_delete=DB_CASCADE on " + "inherited model. Use non DB_* values for this field", + hint="Change the on_delete rule to other options", + obj=field, + id="fields.E325", + ) + ], + ) + + multiple_inheritence_field = MultipleInheritedBar._meta.get_field("another_foo") + self.assertEqual( + multiple_inheritence_field.check(), + [ + Error( + "Field specifies unsupported on_delete=DB_CASCADE on " + "inherited model. Use non DB_* values for this field", + hint="Change the on_delete rule to other options", + obj=multiple_inheritence_field, + id="fields.E325", + ) + ], + ) + def test_check_on_generic_foreign_key(self): class SomeModel(models.Model): some_fk = models.ForeignKey( From ff2c009e2cb452806692210ab69bdd97e35a8dfe Mon Sep 17 00:00:00 2001 From: Akash Kumar Sen Date: Fri, 2 Jun 2023 12:34:18 +0530 Subject: [PATCH 28/60] Change the methhod to classmethod --- django/db/models/fields/related.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/django/db/models/fields/related.py b/django/db/models/fields/related.py index f2ddadd0ba87..8c23411e5f91 100644 --- a/django/db/models/fields/related.py +++ b/django/db/models/fields/related.py @@ -1073,7 +1073,7 @@ def _check_on_delete(self): ) ] elif related_model_status := self._has_related_models_with_db_cascading( - self.model + self.model, on_delete ): return [ checks.Error( @@ -1123,13 +1123,13 @@ def _check_unique(self, **kwargs): else [] ) - def _has_related_models_with_db_cascading(self, model): + @classmethod + def _has_related_models_with_db_cascading(cls, model, on_delete): """ If the foreignkey parent has DB cascading and the Current model has non db cascading return true """ non_db_related_models = {} - on_delete = getattr(self.remote_field, "on_delete", None) # Optimization for the case when the model does not have non-db deletion if not hasattr(model, "_meta") or on_delete in [ DB_CASCADE, From 9f596aee13ff3f135d375bc820293de8a5e46fe0 Mon Sep 17 00:00:00 2001 From: Akash Kumar Sen Date: Sat, 3 Jun 2023 17:13:54 +0530 Subject: [PATCH 29/60] Added support for onetoonefield and inheritance --- django/db/models/fields/related.py | 35 +++++----------- tests/db_level_on_delete/models.py | 32 ++++++++++++++ tests/db_level_on_delete/tests.py | 38 ++++++++++++++++- tests/db_level_on_delete_checks/tests.py | 53 ++++++++++++------------ 4 files changed, 107 insertions(+), 51 deletions(-) diff --git a/django/db/models/fields/related.py b/django/db/models/fields/related.py index 8c23411e5f91..3bf57d31f1ff 100644 --- a/django/db/models/fields/related.py +++ b/django/db/models/fields/related.py @@ -1086,23 +1086,6 @@ def _check_on_delete(self): id="fields.E323", ) ] - elif ( - on_delete in [DB_CASCADE, DB_SET_NULL, DB_RESTRICT] - and hasattr(self.model, "_meta") - and any( - not parent._meta.abstract - for parent in self.model._meta.get_parent_list() - ) - ): - return [ - checks.Error( - f"Field specifies unsupported on_delete={on_delete} on " - "inherited model. Use non DB_* values for this field", - hint="Change the on_delete rule to other options", - obj=self, - id="fields.E325", - ) - ] return [] def _check_unique(self, **kwargs): @@ -1151,14 +1134,18 @@ def _has_related_models_with_db_cascading(cls, model, on_delete): # if they have DB level deletion return True # Our current model already has non_db cascade for rel in rel_model._meta.get_fields(): - if ( - isinstance(rel, ForeignKey) - and hasattr(rel.remote_field, "on_delete") - and rel.remote_field.on_delete - in [DB_CASCADE, DB_SET_NULL, DB_RESTRICT] - ): + related_on_delete = None + related_remote_field = rel + if isinstance(rel, OneToOneRel) and hasattr(rel, "on_delete"): + related_on_delete = rel.on_delete + related_remote_field = rel.remote_field + elif ( + isinstance(rel, ForeignKey) or isinstance(rel, OneToOneField) + ) and hasattr(rel.remote_field, "on_delete"): + related_on_delete = rel.remote_field.on_delete + if related_on_delete in [DB_CASCADE, DB_SET_NULL, DB_RESTRICT]: non_db_related_models["model"] = rel_model - non_db_related_models["field"] = rel + non_db_related_models["field"] = related_remote_field return non_db_related_models return non_db_related_models diff --git a/tests/db_level_on_delete/models.py b/tests/db_level_on_delete/models.py index 78a224cb5089..2f3bf3d234ad 100644 --- a/tests/db_level_on_delete/models.py +++ b/tests/db_level_on_delete/models.py @@ -74,3 +74,35 @@ class AnotherSetNullBaz(models.Model): null=True, ) another_field = models.CharField(max_length=20) + + +class GrandParent(models.Model): + pass + + +class Child(GrandParent): + grandparent_ptr = models.OneToOneField( + GrandParent, primary_key=True, parent_link=True, on_delete=models.DB_RESTRICT + ) + + +class Parent(GrandParent): + grandparent_ptr = models.OneToOneField( + GrandParent, primary_key=True, parent_link=True, on_delete=models.DB_CASCADE + ) + + +class DiamondParent(GrandParent): + gp_ptr = models.OneToOneField( + GrandParent, primary_key=True, parent_link=True, on_delete=models.DB_CASCADE + ) + + +class DiamondChild(Parent, DiamondParent): + parent_ptr = models.OneToOneField( + Parent, primary_key=True, parent_link=True, on_delete=models.DB_CASCADE + ) + + diamondparent_ptr = models.OneToOneField( + DiamondParent, parent_link=True, on_delete=models.DB_CASCADE + ) diff --git a/tests/db_level_on_delete/tests.py b/tests/db_level_on_delete/tests.py index 5090fb3dd999..2f1126e8c6a4 100644 --- a/tests/db_level_on_delete/tests.py +++ b/tests/db_level_on_delete/tests.py @@ -1,11 +1,16 @@ -from django.db import IntegrityError +from django.db import IntegrityError, transaction from django.test import TestCase from .models import ( AnotherSetNullBaz, Bar, Baz, + Child, + DiamondChild, + DiamondParent, Foo, + GrandParent, + Parent, RestrictBar, RestrictBaz, SetNullBar, @@ -123,3 +128,34 @@ def test_queries_on_nested_set_null_cascade(self): # one is the deletion with self.assertNumQueries(1): foo.delete() + + def test_queries_on_inherited_model(self): + gp = GrandParent.objects.create() + parent = Parent.objects.create(grandparent_ptr=gp) + diamond_parent = DiamondParent.objects.create(gp_ptr=gp) + + dc = DiamondChild.objects.create( + parent_ptr=parent, diamondparent_ptr=diamond_parent + ) + + with self.assertNumQueries(1): + gp.delete() + + with self.assertRaises(Parent.DoesNotExist): + parent.refresh_from_db() + + with self.assertRaises(DiamondParent.DoesNotExist): + diamond_parent.refresh_from_db() + + with self.assertRaises(DiamondChild.DoesNotExist): + dc.refresh_from_db() + + def test_restrict_on_inherited_model(self): + gp = GrandParent.objects.create() + child = Child.objects.create(grandparent_ptr=gp) + + with transaction.atomic(): + with self.assertRaises(IntegrityError): + gp.delete() + + child.refresh_from_db() diff --git a/tests/db_level_on_delete_checks/tests.py b/tests/db_level_on_delete_checks/tests.py index 505eb98c2f45..11176aa7f516 100644 --- a/tests/db_level_on_delete_checks/tests.py +++ b/tests/db_level_on_delete_checks/tests.py @@ -70,46 +70,47 @@ class Meta: ) def test_check_on_inherited_models(self): - class AnotherBar(Bar): - another_foo = models.ForeignKey( - Foo, + class GrandParent(models.Model): + pass + + class Parent(GrandParent): + pass + + class DiamondParent(GrandParent): + gp_ptr = models.OneToOneField( + GrandParent, + primary_key=True, + parent_link=True, on_delete=models.DB_CASCADE, ) - class Meta: - managed = False - - class MultipleInheritedBar(Foo, Bar): - another_foo = models.ForeignKey( - Foo, on_delete=models.DB_CASCADE, related_name="another_foo" + class DiamondChild(Parent, DiamondParent): + parent_ptr = models.OneToOneField( + Parent, primary_key=True, parent_link=True, on_delete=models.DB_CASCADE ) - field = AnotherBar._meta.get_field("another_foo") + field = DiamondChild._meta.get_field("diamondparent_ptr") + rel_field = DiamondParent._meta.get_field("gp_ptr") + # error for having DB_CASCADE over normal cascade self.assertEqual( field.check(), [ Error( - "Field specifies unsupported on_delete=DB_CASCADE on " - "inherited model. Use non DB_* values for this field", - hint="Change the on_delete rule to other options", + "Using normal cascading with DB cascading referenced model is " + "prohibited " + f"Related model is {GrandParent} " + f"Related field is {rel_field}", + hint="Use database level cascading for foreignkeys", obj=field, - id="fields.E325", + id="fields.E323", ) ], ) - - multiple_inheritence_field = MultipleInheritedBar._meta.get_field("another_foo") + # No error for entire django cascade + field = Parent._meta.get_field("grandparent_ptr") self.assertEqual( - multiple_inheritence_field.check(), - [ - Error( - "Field specifies unsupported on_delete=DB_CASCADE on " - "inherited model. Use non DB_* values for this field", - hint="Change the on_delete rule to other options", - obj=multiple_inheritence_field, - id="fields.E325", - ) - ], + field.check(), + [], ) def test_check_on_generic_foreign_key(self): From b24835198189e21fb4e3f97693cda6dd0bb659ac Mon Sep 17 00:00:00 2001 From: Akash Kumar Sen Date: Sat, 3 Jun 2023 23:01:59 +0530 Subject: [PATCH 30/60] Update the docs --- docs/ref/models/fields.txt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/ref/models/fields.txt b/docs/ref/models/fields.txt index 078689686b88..94eb94af7cc6 100644 --- a/docs/ref/models/fields.txt +++ b/docs/ref/models/fields.txt @@ -1729,10 +1729,11 @@ any custom methods or send any signals. .. versionadded:: 5.0 ``DB_CASCADE`` is a custom option introduced in Django that allows the - database to handle cascading deletes instead of relying on Django's - emulation of the SQL constraint ``ON DELETE CASCADE``. With ``DB_CASCADE``, - the database itself takes care of deleting related objects when a - referenced object is deleted. + database to handle cascading deletes. This action triggers the SQL + constraint ``ON DELETE RESTRICT`` at the database level during the creation + or modification of the ``ForeignKey``. With ``DB_CASCADE``, the database + itself takes care of deleting related objects when a referenced object + is deleted. :meth:`.Model.delete` isn't called on related models, and the :data:`~django.db.models.signals.pre_delete` and From 159ea378a68b25d562d2ef0c2667c5940b7952c5 Mon Sep 17 00:00:00 2001 From: Akash Kumar Sen Date: Tue, 25 Jul 2023 22:24:37 +0530 Subject: [PATCH 31/60] Add serializer and fix patch typos Co-authored-by: NickStefan --- AUTHORS | 1 + django/db/backends/base/operations.py | 4 +- django/db/migrations/serializer.py | 6 +++ django/db/models/__init__.py | 2 + django/db/models/deletion.py | 8 ++-- django/db/models/fields/related.py | 53 +++++++----------------- docs/ref/models/fields.txt | 4 ++ docs/releases/5.0.txt | 5 +-- tests/db_level_on_delete_checks/tests.py | 14 +++---- 9 files changed, 44 insertions(+), 53 deletions(-) diff --git a/AUTHORS b/AUTHORS index 291b5da657e2..1eef8eceab96 100644 --- a/AUTHORS +++ b/AUTHORS @@ -738,6 +738,7 @@ answer newbie questions, and generally made Django that much better: Nick Presta Nick Sandford Nick Sarbicki + Nick Stefan Niclas Olofsson Nicola Larosa Nicolas Lara diff --git a/django/db/backends/base/operations.py b/django/db/backends/base/operations.py index c6e167ac49a8..fd40e5db6a93 100644 --- a/django/db/backends/base/operations.py +++ b/django/db/backends/base/operations.py @@ -266,10 +266,10 @@ def fk_on_delete_sql(self, operation): statement. """ if operation in ["CASCADE", "SET NULL", "RESTRICT"]: - return " ON DELETE %s " % operation + return f" ON DELETE {operation} " if operation == "": return "" - raise NotImplementedError("ON DELETE %s is not supported." % operation) + raise NotImplementedError(f"ON DELETE {operation} is not supported.") def last_executed_query(self, cursor, sql, params): """ diff --git a/django/db/migrations/serializer.py b/django/db/migrations/serializer.py index 454feaa82971..2967217b36a3 100644 --- a/django/db/migrations/serializer.py +++ b/django/db/migrations/serializer.py @@ -258,6 +258,11 @@ def serialize(self): return "pathlib.%s%r" % (prefix, self.value), {"import pathlib"} +class DatabaseOnDeleteSerializer(BaseSerializer): + def serialize(self): + return f"models.{self.value.name}", {} + + class RegexSerializer(BaseSerializer): def serialize(self): regex_pattern, pattern_imports = serializer_factory( @@ -351,6 +356,7 @@ class Serializer: uuid.UUID: UUIDSerializer, pathlib.PurePath: PathSerializer, os.PathLike: PathLikeSerializer, + models.DatabaseOnDelete: DatabaseOnDeleteSerializer, } @classmethod diff --git a/django/db/models/__init__.py b/django/db/models/__init__.py index 3200b4694c7b..b7d71527bcbb 100644 --- a/django/db/models/__init__.py +++ b/django/db/models/__init__.py @@ -15,6 +15,7 @@ SET, SET_DEFAULT, SET_NULL, + DatabaseOnDelete, ProtectedError, RestrictedError, ) @@ -118,4 +119,5 @@ "DB_CASCADE", "DB_RESTRICT", "DB_SET_NULL", + "DatabaseOnDelete", ] diff --git a/django/db/models/deletion.py b/django/db/models/deletion.py index 53a561a3ae06..d2abf5d9388d 100644 --- a/django/db/models/deletion.py +++ b/django/db/models/deletion.py @@ -87,9 +87,11 @@ class DatabaseOnDelete: def __init__(self, operation, name): self.operation = operation self.name = name + self.__name__ = name - def __call__(self, collector, field, sub_objs, using): - pass + # These objects must be callable, as we are calling it in the collect + # method of Collector + __call__ = DO_NOTHING def as_sql(self, connection): return connection.ops.fk_on_delete_sql(self.operation) @@ -343,7 +345,7 @@ def collect( continue field = related.field on_delete = field.remote_field.on_delete - if on_delete in [DO_NOTHING, DB_CASCADE, DB_SET_NULL, DB_RESTRICT]: + if on_delete == DO_NOTHING or isinstance(on_delete, DatabaseOnDelete): continue related_model = related.related_model if self.can_fast_delete(related_model, from_field=field): diff --git a/django/db/models/fields/related.py b/django/db/models/fields/related.py index 3bf57d31f1ff..08203b71b263 100644 --- a/django/db/models/fields/related.py +++ b/django/db/models/fields/related.py @@ -13,11 +13,10 @@ from django.db.models.constants import LOOKUP_SEP from django.db.models.deletion import ( CASCADE, - DB_CASCADE, - DB_RESTRICT, DB_SET_NULL, SET_DEFAULT, SET_NULL, + DatabaseOnDelete, ) from django.db.models.query_utils import PathInfo from django.db.models.utils import make_model_tuple @@ -1016,22 +1015,11 @@ def check(self, **kwargs): def _check_on_delete(self): on_delete = getattr(self.remote_field, "on_delete", None) - if on_delete == DB_SET_NULL and not self.null: + if on_delete in [SET_NULL, DB_SET_NULL] and not self.null: return [ checks.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=self, - id="fields.E320", - ) - ] - elif on_delete == SET_NULL and not self.null: - return [ - 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." @@ -1050,7 +1038,7 @@ def _check_on_delete(self): ) ] elif ( - on_delete in [DB_CASCADE, DB_SET_NULL, DB_RESTRICT] + isinstance(on_delete, DatabaseOnDelete) and hasattr(self.model, "_meta") and ( any( # generic relation @@ -1072,15 +1060,14 @@ def _check_on_delete(self): id="fields.E345", ) ] - elif related_model_status := self._has_related_models_with_db_cascading( + elif related_model_field := self._has_related_models_with_db_cascading( self.model, on_delete ): return [ checks.Error( - "Using normal cascading with DB cascading referenced model is " - "prohibited " - f"Related model is {related_model_status.get('model')} " - f"Related field is {related_model_status.get('field')}", + "Using python based on_delete with database " + "level on_delete referenced model is prohibited " + f"Related field is {related_model_field}.", hint="Use database level cascading for foreignkeys", obj=self, id="fields.E323", @@ -1109,19 +1096,14 @@ def _check_unique(self, **kwargs): @classmethod def _has_related_models_with_db_cascading(cls, model, on_delete): """ - If the foreignkey parent has DB cascading and the Current model has non + If the ForeignKey parent has DB cascading and the Current model has non db cascading return true """ - non_db_related_models = {} # Optimization for the case when the model does not have non-db deletion - if not hasattr(model, "_meta") or on_delete in [ - DB_CASCADE, - DB_SET_NULL, - DB_RESTRICT, - ]: - return non_db_related_models + if isinstance(on_delete, DatabaseOnDelete): + return None # Fetch all the models related to the current model - # In other words fetch all the foreignkey childs. + # In other words fetch all the ForeignKey childs. related_models = [ rel.related_model for rel in model._meta.get_fields() @@ -1143,12 +1125,9 @@ def _has_related_models_with_db_cascading(cls, model, on_delete): isinstance(rel, ForeignKey) or isinstance(rel, OneToOneField) ) and hasattr(rel.remote_field, "on_delete"): related_on_delete = rel.remote_field.on_delete - if related_on_delete in [DB_CASCADE, DB_SET_NULL, DB_RESTRICT]: - non_db_related_models["model"] = rel_model - non_db_related_models["field"] = related_remote_field - return non_db_related_models - - return non_db_related_models + if isinstance(related_on_delete, DatabaseOnDelete): + return related_remote_field + return None def deconstruct(self): name, path, args, kwargs = super().deconstruct() diff --git a/docs/ref/models/fields.txt b/docs/ref/models/fields.txt index 94eb94af7cc6..ff994b274d09 100644 --- a/docs/ref/models/fields.txt +++ b/docs/ref/models/fields.txt @@ -1627,6 +1627,10 @@ Django perform the relevant actions. The database variants are normally more efficient because they require less data to be fetched, but they don’t call any custom methods or send any signals. +.. versionchanged:: 5.0 + + Support for ``DB_*`` variants of on_delete attribute is added + * .. attribute:: CASCADE Cascade deletes. Django emulates the behavior of the SQL constraint ON diff --git a/docs/releases/5.0.txt b/docs/releases/5.0.txt index c30a9cc5c9ae..342cb053281d 100644 --- a/docs/releases/5.0.txt +++ b/docs/releases/5.0.txt @@ -126,9 +126,8 @@ sets a database-computed default value. For example:: Database-level values for ``ForeignKey.on_delete`` -------------------------------------------------- -:class:`~django.db.models.ForeignKey` -:attr:`~django.db.models.ForeignKey.on_delete` now supports three extra values -to specify database-level actions: +:attr:`ForeignKey.on_delete ` now +supports three extra values to specify database-level actions: * ``DB_CASCADE`` - deletes the referring object. * ``DB_RESTRICT`` - prevents deletion of referred-to objects. diff --git a/tests/db_level_on_delete_checks/tests.py b/tests/db_level_on_delete_checks/tests.py index 11176aa7f516..f43a5053d8dd 100644 --- a/tests/db_level_on_delete_checks/tests.py +++ b/tests/db_level_on_delete_checks/tests.py @@ -26,10 +26,9 @@ class Baz(models.Model): baz_field.check(), [ Error( - "Using normal cascading with DB cascading referenced model is " - "prohibited " - f"Related model is {related_model_status.get('model')} " - f"Related field is {related_model_status.get('field')}", + "Using python based on_delete with database " + "level on_delete referenced model is prohibited " + f"Related field is {related_model_status.get('field')}.", hint="Use database level cascading for foreignkeys", obj=baz_field, id="fields.E323", @@ -96,10 +95,9 @@ class DiamondChild(Parent, DiamondParent): field.check(), [ Error( - "Using normal cascading with DB cascading referenced model is " - "prohibited " - f"Related model is {GrandParent} " - f"Related field is {rel_field}", + "Using python based on_delete with database " + "level on_delete referenced model is prohibited " + f"Related field is {rel_field}.", hint="Use database level cascading for foreignkeys", obj=field, id="fields.E323", From 9222416dd7e9fde685d3d76f04c4d6d81fcd3eea Mon Sep 17 00:00:00 2001 From: Akash-Kumar-Sen Date: Wed, 26 Jul 2023 11:02:54 +0530 Subject: [PATCH 32/60] Document checks --- AUTHORS | 2 +- django/db/models/fields/related.py | 2 +- docs/ref/checks.txt | 7 ++++++- tests/db_level_on_delete_checks/tests.py | 2 +- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/AUTHORS b/AUTHORS index 1eef8eceab96..1ea44b3e026f 100644 --- a/AUTHORS +++ b/AUTHORS @@ -738,7 +738,7 @@ answer newbie questions, and generally made Django that much better: Nick Presta Nick Sandford Nick Sarbicki - Nick Stefan + Nick Stefan Niclas Olofsson Nicola Larosa Nicolas Lara diff --git a/django/db/models/fields/related.py b/django/db/models/fields/related.py index 08203b71b263..13aba323b4b5 100644 --- a/django/db/models/fields/related.py +++ b/django/db/models/fields/related.py @@ -1057,7 +1057,7 @@ def _check_on_delete(self): "declaring a GenericForeignKey.", hint="Change the on_delete rule to a non DB_* method", obj=self, - id="fields.E345", + id="fields.E322", ) ] elif related_model_field := self._has_related_models_with_db_cascading( diff --git a/docs/ref/checks.txt b/docs/ref/checks.txt index df0adbef6344..06ed0aed2173 100644 --- a/docs/ref/checks.txt +++ b/docs/ref/checks.txt @@ -280,9 +280,14 @@ Related fields referenced by a ``ForeignKey``. * **fields.E312**: The ``to_field`` ```` doesn't exist on the related model ``.``. -* **fields.E320**: Field specifies ``on_delete=SET_NULL``, but cannot be null. +* **fields.E320**: Field specifies ``on_delete=SET_NULL``, or + ``on_delete=DB_SET_NULL`` but cannot be null. * **fields.E321**: The field specifies ``on_delete=SET_DEFAULT``, but has no default value. +* **fields.E322**: The field specifies unsuported database level ``on_delete`` + on model declaring a GenericForeignKey. +* **fields.E323**: Using python based on_delete with database level on_delete + referenced parent model is prohibited. * **fields.E330**: ``ManyToManyField``\s cannot be unique. * **fields.E331**: Field specifies a many-to-many relation through model ````, which has not been installed. diff --git a/tests/db_level_on_delete_checks/tests.py b/tests/db_level_on_delete_checks/tests.py index f43a5053d8dd..5baa8db97891 100644 --- a/tests/db_level_on_delete_checks/tests.py +++ b/tests/db_level_on_delete_checks/tests.py @@ -143,7 +143,7 @@ class Meta: "declaring a GenericForeignKey.", hint="Change the on_delete rule to a non DB_* method", obj=comment_field, - id="fields.E345", + id="fields.E322", ) ], ) From 745524a342fc39bd8e20a8cc1b8ecf4767d88a2f Mon Sep 17 00:00:00 2001 From: Akash-Kumar-Sen Date: Wed, 26 Jul 2023 11:14:42 +0530 Subject: [PATCH 33/60] Doc fixes --- docs/ref/checks.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ref/checks.txt b/docs/ref/checks.txt index 06ed0aed2173..0d5126f98b1c 100644 --- a/docs/ref/checks.txt +++ b/docs/ref/checks.txt @@ -284,7 +284,7 @@ Related fields ``on_delete=DB_SET_NULL`` but cannot be null. * **fields.E321**: The field specifies ``on_delete=SET_DEFAULT``, but has no default value. -* **fields.E322**: The field specifies unsuported database level ``on_delete`` +* **fields.E322**: The field specifies unsupported database level ``on_delete`` on model declaring a GenericForeignKey. * **fields.E323**: Using python based on_delete with database level on_delete referenced parent model is prohibited. From aa79fb3b20a2afd6fde7700a1e9d46d7220f2d34 Mon Sep 17 00:00:00 2001 From: Akash-Kumar-Sen Date: Thu, 3 Aug 2023 11:43:07 +0530 Subject: [PATCH 34/60] Add d --- django/db/backends/base/operations.py | 2 +- django/db/models/__init__.py | 2 ++ django/db/models/deletion.py | 4 ++-- django/db/models/fields/__init__.py | 4 ++++ django/db/models/fields/related.py | 11 ++++++++++ docs/ref/checks.txt | 8 +++++--- docs/ref/models/fields.txt | 11 ++++++++++ docs/releases/5.0.txt | 4 +++- tests/db_level_on_delete/models.py | 26 +++++++++++++++--------- tests/db_level_on_delete/tests.py | 16 ++++++++++++--- tests/db_level_on_delete_checks/tests.py | 25 +++++++++++++++++++++++ 11 files changed, 93 insertions(+), 20 deletions(-) diff --git a/django/db/backends/base/operations.py b/django/db/backends/base/operations.py index fd40e5db6a93..b521261ce08b 100644 --- a/django/db/backends/base/operations.py +++ b/django/db/backends/base/operations.py @@ -265,7 +265,7 @@ def fk_on_delete_sql(self, operation): Return the SQL to make an ON DELETE statement during a CREATE TABLE statement. """ - if operation in ["CASCADE", "SET NULL", "RESTRICT"]: + if operation in ["CASCADE", "SET NULL", "RESTRICT", "SET DEFAULT"]: return f" ON DELETE {operation} " if operation == "": return "" diff --git a/django/db/models/__init__.py b/django/db/models/__init__.py index b7d71527bcbb..d293caa30665 100644 --- a/django/db/models/__init__.py +++ b/django/db/models/__init__.py @@ -8,6 +8,7 @@ CASCADE, DB_CASCADE, DB_RESTRICT, + DB_SET_DEFAULT, DB_SET_NULL, DO_NOTHING, PROTECT, @@ -119,5 +120,6 @@ "DB_CASCADE", "DB_RESTRICT", "DB_SET_NULL", + "DB_SET_DEFAULT", "DatabaseOnDelete", ] diff --git a/django/db/models/deletion.py b/django/db/models/deletion.py index d2abf5d9388d..3ec5429c25aa 100644 --- a/django/db/models/deletion.py +++ b/django/db/models/deletion.py @@ -86,7 +86,6 @@ def DO_NOTHING(collector, field, sub_objs, using): class DatabaseOnDelete: def __init__(self, operation, name): self.operation = operation - self.name = name self.__name__ = name # These objects must be callable, as we are calling it in the collect @@ -97,12 +96,13 @@ def as_sql(self, connection): return connection.ops.fk_on_delete_sql(self.operation) def __str__(self): - return self.name + return self.__name__ DB_CASCADE = DatabaseOnDelete("CASCADE", "DB_CASCADE") DB_SET_NULL = DatabaseOnDelete("SET NULL", "DB_SET_NULL") DB_RESTRICT = DatabaseOnDelete("RESTRICT", "DB_RESTRICT") +DB_SET_DEFAULT = DatabaseOnDelete("SET DEFAULT", "DB_SET_DEFAULT") def get_candidate_relations_to_delete(opts): diff --git a/django/db/models/fields/__init__.py b/django/db/models/fields/__init__.py index 18b48c0e72e3..c377e48a594c 100644 --- a/django/db/models/fields/__init__.py +++ b/django/db/models/fields/__init__.py @@ -1009,6 +1009,10 @@ def has_default(self): """Return a boolean of whether this field has a default value.""" return self.default is not NOT_PROVIDED + def has_db_default(self): + """Return a boolean of whether this field has a db_default value.""" + return self.db_default is not NOT_PROVIDED + def get_default(self): """Return the default value for this field.""" return self._get_default() diff --git a/django/db/models/fields/related.py b/django/db/models/fields/related.py index 13aba323b4b5..9a14aa3d632d 100644 --- a/django/db/models/fields/related.py +++ b/django/db/models/fields/related.py @@ -13,6 +13,7 @@ from django.db.models.constants import LOOKUP_SEP from django.db.models.deletion import ( CASCADE, + DB_SET_DEFAULT, DB_SET_NULL, SET_DEFAULT, SET_NULL, @@ -1037,6 +1038,16 @@ def _check_on_delete(self): id="fields.E321", ) ] + elif on_delete == DB_SET_DEFAULT and not self.has_db_default(): + return [ + 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.E324", + ) + ] elif ( isinstance(on_delete, DatabaseOnDelete) and hasattr(self.model, "_meta") diff --git a/docs/ref/checks.txt b/docs/ref/checks.txt index 0d5126f98b1c..b1b4dfde4ae4 100644 --- a/docs/ref/checks.txt +++ b/docs/ref/checks.txt @@ -285,9 +285,11 @@ Related fields * **fields.E321**: The field specifies ``on_delete=SET_DEFAULT``, but has no default value. * **fields.E322**: The field specifies unsupported database level ``on_delete`` - on model declaring a GenericForeignKey. -* **fields.E323**: Using python based on_delete with database level on_delete - referenced parent model is prohibited. + on model declaring a ``GenericForeignKey``. +* **fields.E323**: Using python based on_delete with database level + ``on_delete`` referenced parent model is prohibited. +* **fields.E324**: The field specifies ``on_delete=DB_SET_DEFAULT``, but has no + ``db_default`` value. * **fields.E330**: ``ManyToManyField``\s cannot be unique. * **fields.E331**: Field specifies a many-to-many relation through model ````, which has not been installed. diff --git a/docs/ref/models/fields.txt b/docs/ref/models/fields.txt index ff994b274d09..17249bf9988d 100644 --- a/docs/ref/models/fields.txt +++ b/docs/ref/models/fields.txt @@ -1764,6 +1764,17 @@ any custom methods or send any signals. ``ON DELETE SET NULL`` on a database level while creating or altering the ``ForeignKey``. +* .. attribute:: DB_SET_DEFAULT + + .. versionadded:: 5.0 + + Set the :class:`ForeignKey` value to its :attr:`Field.db_default` value. + the respective field must have a :attr:`Field.db_default` value present. + This triggers SQL constraint ``ON DELETE SET DEFAULT`` on a database level + while creating or altering the ``ForeignKey``.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. + .. attribute:: ForeignKey.limit_choices_to Sets a limit to the available choices for this field when this field is diff --git a/docs/releases/5.0.txt b/docs/releases/5.0.txt index 342cb053281d..4a71b46408fd 100644 --- a/docs/releases/5.0.txt +++ b/docs/releases/5.0.txt @@ -127,11 +127,13 @@ Database-level values for ``ForeignKey.on_delete`` -------------------------------------------------- :attr:`ForeignKey.on_delete ` now -supports three extra values to specify database-level actions: +supports four extra values to specify database-level actions: * ``DB_CASCADE`` - deletes the referring object. * ``DB_RESTRICT`` - prevents deletion of referred-to objects. * ``DB_SET_NULL`` - sets the referring foreign key to SQL ``NULL``. +* ``DB_SET_DEFAULT`` - the value of the foreign key column in the referencing + table will be set to its ``db_default`` value. Minor features -------------- diff --git a/tests/db_level_on_delete/models.py b/tests/db_level_on_delete/models.py index 2f3bf3d234ad..a9221755c970 100644 --- a/tests/db_level_on_delete/models.py +++ b/tests/db_level_on_delete/models.py @@ -76,25 +76,21 @@ class AnotherSetNullBaz(models.Model): another_field = models.CharField(max_length=20) -class GrandParent(models.Model): - pass - - -class Child(GrandParent): +class Child(Foo): grandparent_ptr = models.OneToOneField( - GrandParent, primary_key=True, parent_link=True, on_delete=models.DB_RESTRICT + Foo, primary_key=True, parent_link=True, on_delete=models.DB_RESTRICT ) -class Parent(GrandParent): +class Parent(Foo): grandparent_ptr = models.OneToOneField( - GrandParent, primary_key=True, parent_link=True, on_delete=models.DB_CASCADE + Foo, primary_key=True, parent_link=True, on_delete=models.DB_CASCADE ) -class DiamondParent(GrandParent): +class DiamondParent(Foo): gp_ptr = models.OneToOneField( - GrandParent, primary_key=True, parent_link=True, on_delete=models.DB_CASCADE + Foo, primary_key=True, parent_link=True, on_delete=models.DB_CASCADE ) @@ -106,3 +102,13 @@ class DiamondChild(Parent, DiamondParent): diamondparent_ptr = models.OneToOneField( DiamondParent, parent_link=True, on_delete=models.DB_CASCADE ) + + +class DBDefaultsPK(models.Model): + language_code = models.CharField(primary_key=True, max_length=2, db_default="en") + + +class DBDefaultsFK(models.Model): + language_code = models.ForeignKey( + DBDefaultsPK, db_default="fr", on_delete=models.DB_SET_DEFAULT + ) diff --git a/tests/db_level_on_delete/tests.py b/tests/db_level_on_delete/tests.py index 2f1126e8c6a4..3a3702ccec76 100644 --- a/tests/db_level_on_delete/tests.py +++ b/tests/db_level_on_delete/tests.py @@ -6,10 +6,11 @@ Bar, Baz, Child, + DBDefaultsFK, + DBDefaultsPK, DiamondChild, DiamondParent, Foo, - GrandParent, Parent, RestrictBar, RestrictBaz, @@ -85,6 +86,15 @@ def test_nested_set_null_on_deletion(self): self.assertEqual(baz.setnullbar, orphan_baz.setnullbar) self.assertIsNotNone(orphan_baz.setnullbar) + def test_foreign_key_db_default(self): + default_parent = DBDefaultsPK.objects.create(language_code="fr") + parent = DBDefaultsPK.objects.create(language_code="en") + child1 = DBDefaultsFK.objects.create(language_code=parent) + with self.assertNumQueries(1): + parent.delete() + child1.refresh_from_db() + self.assertEqual(child1.language_code, default_parent) + class DatabaseLevelOnDeleteQueryAssertionTests(TestCase): def test_queries_on_nested_cascade(self): @@ -130,7 +140,7 @@ def test_queries_on_nested_set_null_cascade(self): foo.delete() def test_queries_on_inherited_model(self): - gp = GrandParent.objects.create() + gp = Foo.objects.create() parent = Parent.objects.create(grandparent_ptr=gp) diamond_parent = DiamondParent.objects.create(gp_ptr=gp) @@ -151,7 +161,7 @@ def test_queries_on_inherited_model(self): dc.refresh_from_db() def test_restrict_on_inherited_model(self): - gp = GrandParent.objects.create() + gp = Foo.objects.create() child = Child.objects.create(grandparent_ptr=gp) with transaction.atomic(): diff --git a/tests/db_level_on_delete_checks/tests.py b/tests/db_level_on_delete_checks/tests.py index 5baa8db97891..654e0b9e3314 100644 --- a/tests/db_level_on_delete_checks/tests.py +++ b/tests/db_level_on_delete_checks/tests.py @@ -153,3 +153,28 @@ class Meta: photo_field.check(), [], ) + + def test_check_db_default_foreign_key(self): + class DBDefaultsPK(models.Model): + language_code = models.CharField( + primary_key=True, max_length=2, db_default="en" + ) + + class DBDefaultsFK(models.Model): + language_code = models.ForeignKey( + DBDefaultsPK, on_delete=models.DB_SET_DEFAULT + ) + + fk_field = DBDefaultsFK._meta.get_field("language_code") + self.assertEqual( + fk_field.check(), + [ + 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=fk_field, + id="fields.E324", + ) + ], + ) From fd48e154984ab883d9f935a1b802fc18fa675d85 Mon Sep 17 00:00:00 2001 From: Akash-Kumar-Sen Date: Thu, 3 Aug 2023 12:00:19 +0530 Subject: [PATCH 35/60] Add support for DB_SET_DEFAULT --- tests/db_level_on_delete_checks/tests.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/db_level_on_delete_checks/tests.py b/tests/db_level_on_delete_checks/tests.py index 654e0b9e3314..675c56e9373e 100644 --- a/tests/db_level_on_delete_checks/tests.py +++ b/tests/db_level_on_delete_checks/tests.py @@ -165,6 +165,9 @@ class DBDefaultsFK(models.Model): DBDefaultsPK, on_delete=models.DB_SET_DEFAULT ) + class Meta: + abstract = True + fk_field = DBDefaultsFK._meta.get_field("language_code") self.assertEqual( fk_field.check(), From 5f6b7015a5614e834262b429fd28e1227d3523fb Mon Sep 17 00:00:00 2001 From: Akash-Kumar-Sen Date: Sun, 20 Aug 2023 18:44:13 +0530 Subject: [PATCH 36/60] Change the location for tests --- tests/db_level_on_delete/__init__.py | 0 tests/db_level_on_delete/models.py | 114 ----------- tests/db_level_on_delete/tests.py | 171 ---------------- tests/db_level_on_delete_checks/__init__.py | 0 tests/db_level_on_delete_checks/models.py | 12 -- tests/db_level_on_delete_checks/tests.py | 183 ------------------ tests/delete/models.py | 109 +++++++++++ tests/delete/tests.py | 167 +++++++++++++++- .../test_relative_fields.py | 173 +++++++++++++++++ 9 files changed, 448 insertions(+), 481 deletions(-) delete mode 100644 tests/db_level_on_delete/__init__.py delete mode 100644 tests/db_level_on_delete/models.py delete mode 100644 tests/db_level_on_delete/tests.py delete mode 100644 tests/db_level_on_delete_checks/__init__.py delete mode 100644 tests/db_level_on_delete_checks/models.py delete mode 100644 tests/db_level_on_delete_checks/tests.py diff --git a/tests/db_level_on_delete/__init__.py b/tests/db_level_on_delete/__init__.py deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/tests/db_level_on_delete/models.py b/tests/db_level_on_delete/models.py deleted file mode 100644 index a9221755c970..000000000000 --- a/tests/db_level_on_delete/models.py +++ /dev/null @@ -1,114 +0,0 @@ -from django.db import models - - -class Foo(models.Model): - """Initial model named Foo""" - - -class Bar(models.Model): - """First level foreignkey child for Foo - Implemented using database level cascading""" - - foo = models.ForeignKey( - Foo, - on_delete=models.DB_CASCADE, - ) - - -class Baz(models.Model): - """Second level foreignkey child for Foo - Implemented using in DB cascading""" - - bar = models.ForeignKey( - Bar, - on_delete=models.DB_CASCADE, - ) - - -class RestrictBar(models.Model): - """First level child of foo with cascading set to restrict""" - - foo = models.ForeignKey( - Foo, - on_delete=models.DB_RESTRICT, - ) - - -class RestrictBaz(models.Model): - """Second level child of foo with cascading set to restrict""" - - bar = models.ForeignKey( - Bar, - on_delete=models.DB_RESTRICT, - ) - - -class SetNullBar(models.Model): - """First level child of foo with cascading set to null""" - - foo = models.ForeignKey( - Foo, - on_delete=models.DB_SET_NULL, - null=True, - ) - another_field = models.CharField(max_length=20) - - -class SetNullBaz(models.Model): - """Second level child of foo with cascading set to null""" - - bar = models.ForeignKey( - Bar, - on_delete=models.DB_SET_NULL, - null=True, - ) - another_field = models.CharField(max_length=20) - - -class AnotherSetNullBaz(models.Model): - """Second level child of foo with cascading set to null""" - - setnullbar = models.ForeignKey( - SetNullBar, - on_delete=models.DB_SET_NULL, - null=True, - ) - another_field = models.CharField(max_length=20) - - -class Child(Foo): - grandparent_ptr = models.OneToOneField( - Foo, primary_key=True, parent_link=True, on_delete=models.DB_RESTRICT - ) - - -class Parent(Foo): - grandparent_ptr = models.OneToOneField( - Foo, primary_key=True, parent_link=True, on_delete=models.DB_CASCADE - ) - - -class DiamondParent(Foo): - gp_ptr = models.OneToOneField( - Foo, primary_key=True, parent_link=True, on_delete=models.DB_CASCADE - ) - - -class DiamondChild(Parent, DiamondParent): - parent_ptr = models.OneToOneField( - Parent, primary_key=True, parent_link=True, on_delete=models.DB_CASCADE - ) - - diamondparent_ptr = models.OneToOneField( - DiamondParent, parent_link=True, on_delete=models.DB_CASCADE - ) - - -class DBDefaultsPK(models.Model): - language_code = models.CharField(primary_key=True, max_length=2, db_default="en") - - -class DBDefaultsFK(models.Model): - language_code = models.ForeignKey( - DBDefaultsPK, db_default="fr", on_delete=models.DB_SET_DEFAULT - ) diff --git a/tests/db_level_on_delete/tests.py b/tests/db_level_on_delete/tests.py deleted file mode 100644 index 3a3702ccec76..000000000000 --- a/tests/db_level_on_delete/tests.py +++ /dev/null @@ -1,171 +0,0 @@ -from django.db import IntegrityError, transaction -from django.test import TestCase - -from .models import ( - AnotherSetNullBaz, - Bar, - Baz, - Child, - DBDefaultsFK, - DBDefaultsPK, - DiamondChild, - DiamondParent, - Foo, - Parent, - RestrictBar, - RestrictBaz, - SetNullBar, - SetNullBaz, -) - - -class DatabaseLevelOnDeleteTests(TestCase): - def test_deletion_on_nested_cascades(self): - foo = Foo.objects.create() - bar = Bar.objects.create(foo=foo) - baz = Baz.objects.create(bar=bar) - - foo.delete() - - with self.assertRaises(Bar.DoesNotExist): - bar.refresh_from_db() - - with self.assertRaises(Baz.DoesNotExist): - baz.refresh_from_db() - - def test_restricted_deletion(self): - foo = Foo.objects.create() - RestrictBar.objects.create(foo=foo) - - with self.assertRaises(IntegrityError): - foo.delete() - - def test_restricted_deletion_by_cascade(self): - foo = Foo.objects.create() - bar = Bar.objects.create(foo=foo) - RestrictBaz.objects.create(bar=bar) - with self.assertRaises(IntegrityError): - foo.delete() - - def test_deletion_on_set_null(self): - foo = Foo.objects.create() - bar = SetNullBar.objects.create(foo=foo, another_field="Some Value") - foo.delete() - orphan_bar = SetNullBar.objects.get(pk=bar.pk) - self.assertEqual(bar.pk, orphan_bar.pk) - self.assertEqual(bar.another_field, orphan_bar.another_field) - self.assertNotEqual(bar.foo, orphan_bar.foo) - self.assertIsNone(orphan_bar.foo) - - def test_set_null_on_cascade_deletion(self): - foo = Foo.objects.create() - bar = Bar.objects.create(foo=foo) - baz = SetNullBaz.objects.create(bar=bar, another_field="Some Value") - foo.delete() - orphan_baz = SetNullBaz.objects.get(pk=baz.pk) - self.assertEqual(baz.pk, orphan_baz.pk) - self.assertEqual(baz.another_field, orphan_baz.another_field) - self.assertNotEqual(baz.bar, orphan_baz.bar) - self.assertIsNone(orphan_baz.bar) - - def test_nested_set_null_on_deletion(self): - foo = Foo.objects.create() - bar = SetNullBar.objects.create(foo=foo) - baz = AnotherSetNullBaz.objects.create(setnullbar=bar) - foo.delete() - - orphan_bar = SetNullBar.objects.get(pk=bar.pk) - self.assertEqual(bar.pk, orphan_bar.pk) - self.assertEqual(bar.another_field, orphan_bar.another_field) - self.assertNotEqual(bar.foo, orphan_bar.foo) - self.assertIsNone(orphan_bar.foo) - - orphan_baz = AnotherSetNullBaz.objects.get(pk=baz.pk) - self.assertEqual(baz.pk, orphan_baz.pk) - self.assertEqual(baz.another_field, orphan_baz.another_field) - self.assertEqual(baz.setnullbar, orphan_baz.setnullbar) - self.assertIsNotNone(orphan_baz.setnullbar) - - def test_foreign_key_db_default(self): - default_parent = DBDefaultsPK.objects.create(language_code="fr") - parent = DBDefaultsPK.objects.create(language_code="en") - child1 = DBDefaultsFK.objects.create(language_code=parent) - with self.assertNumQueries(1): - parent.delete() - child1.refresh_from_db() - self.assertEqual(child1.language_code, default_parent) - - -class DatabaseLevelOnDeleteQueryAssertionTests(TestCase): - def test_queries_on_nested_cascade(self): - foo = Foo.objects.create() - - for i in range(3): - Bar.objects.create(foo=foo) - - for bar in Bar.objects.all(): - for i in range(3): - Baz.objects.create(bar=bar) - - # one is the deletion - with self.assertNumQueries(1): - foo.delete() - - def test_queries_on_nested_set_null(self): - foo = Foo.objects.create() - - for i in range(3): - SetNullBar.objects.create(foo=foo) - - for setnullbar in SetNullBar.objects.all(): - for i in range(3): - AnotherSetNullBaz.objects.create(setnullbar=setnullbar) - - # one is the deletion - with self.assertNumQueries(1): - foo.delete() - - def test_queries_on_nested_set_null_cascade(self): - foo = Foo.objects.create() - - for i in range(3): - Bar.objects.create(foo=foo) - - for bar in Bar.objects.all(): - for i in range(3): - SetNullBaz.objects.create(bar=bar) - - # one is the deletion - with self.assertNumQueries(1): - foo.delete() - - def test_queries_on_inherited_model(self): - gp = Foo.objects.create() - parent = Parent.objects.create(grandparent_ptr=gp) - diamond_parent = DiamondParent.objects.create(gp_ptr=gp) - - dc = DiamondChild.objects.create( - parent_ptr=parent, diamondparent_ptr=diamond_parent - ) - - with self.assertNumQueries(1): - gp.delete() - - with self.assertRaises(Parent.DoesNotExist): - parent.refresh_from_db() - - with self.assertRaises(DiamondParent.DoesNotExist): - diamond_parent.refresh_from_db() - - with self.assertRaises(DiamondChild.DoesNotExist): - dc.refresh_from_db() - - def test_restrict_on_inherited_model(self): - gp = Foo.objects.create() - child = Child.objects.create(grandparent_ptr=gp) - - with transaction.atomic(): - with self.assertRaises(IntegrityError): - gp.delete() - - child.refresh_from_db() diff --git a/tests/db_level_on_delete_checks/__init__.py b/tests/db_level_on_delete_checks/__init__.py deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/tests/db_level_on_delete_checks/models.py b/tests/db_level_on_delete_checks/models.py deleted file mode 100644 index 222a1fefc00c..000000000000 --- a/tests/db_level_on_delete_checks/models.py +++ /dev/null @@ -1,12 +0,0 @@ -from django.db import models - - -class Foo(models.Model): - """Initial model named Foo""" - - -class Bar(models.Model): - foo = models.ForeignKey( - Foo, - on_delete=models.DB_CASCADE, - ) diff --git a/tests/db_level_on_delete_checks/tests.py b/tests/db_level_on_delete_checks/tests.py deleted file mode 100644 index 675c56e9373e..000000000000 --- a/tests/db_level_on_delete_checks/tests.py +++ /dev/null @@ -1,183 +0,0 @@ -from django.contrib.contenttypes.fields import GenericForeignKey -from django.contrib.contenttypes.models import ContentType -from django.core.checks import Error -from django.db import models -from django.test import TestCase - -from .models import Bar, Foo - - -class DatabaseLevelCascadeCheckTests(TestCase): - def test_system_check_on_nested_db_with_non_db_cascading(self): - class BadBar(models.Model): - foo = models.ForeignKey(Foo, on_delete=models.CASCADE) - - class Baz(models.Model): - """First level child""" - - bar = models.ForeignKey( - Bar, - on_delete=models.CASCADE, - ) - - baz_field = Baz._meta.get_field("bar") - related_model_status = {"model": Bar, "field": Bar._meta.get_field("foo")} - self.assertEqual( - baz_field.check(), - [ - Error( - "Using python based on_delete with database " - "level on_delete referenced model is prohibited " - f"Related field is {related_model_status.get('field')}.", - hint="Use database level cascading for foreignkeys", - obj=baz_field, - id="fields.E323", - ), - ], - ) - - bad_bar_field = BadBar._meta.get_field("foo") - self.assertEqual(bad_bar_field.check(), []) - - bar_field = Bar._meta.get_field("foo") - self.assertEqual(bar_field.check(), []) - - def test_null_condition_with_set_null_db(self): - class SetNullDbNotNullModel(models.Model): - foo = models.ForeignKey( - Foo, - on_delete=models.DB_SET_NULL, - ) - - class Meta: - managed = False - - field = SetNullDbNotNullModel._meta.get_field("foo") - 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_check_on_inherited_models(self): - class GrandParent(models.Model): - pass - - class Parent(GrandParent): - pass - - class DiamondParent(GrandParent): - gp_ptr = models.OneToOneField( - GrandParent, - primary_key=True, - parent_link=True, - on_delete=models.DB_CASCADE, - ) - - class DiamondChild(Parent, DiamondParent): - parent_ptr = models.OneToOneField( - Parent, primary_key=True, parent_link=True, on_delete=models.DB_CASCADE - ) - - field = DiamondChild._meta.get_field("diamondparent_ptr") - rel_field = DiamondParent._meta.get_field("gp_ptr") - # error for having DB_CASCADE over normal cascade - self.assertEqual( - field.check(), - [ - Error( - "Using python based on_delete with database " - "level on_delete referenced model is prohibited " - f"Related field is {rel_field}.", - hint="Use database level cascading for foreignkeys", - obj=field, - id="fields.E323", - ) - ], - ) - # No error for entire django cascade - field = Parent._meta.get_field("grandparent_ptr") - self.assertEqual( - field.check(), - [], - ) - - def test_check_on_generic_foreign_key(self): - class SomeModel(models.Model): - some_fk = models.ForeignKey( - ContentType, - on_delete=models.DB_CASCADE, - related_name="ctcmnt", - ) - object_id = models.PositiveIntegerField() - content_object = GenericForeignKey("some_fk", "object_id") - - class Meta: - abstract = True - - class SomeAnotherModel(models.Model): - another_fk = models.ForeignKey( - ContentType, on_delete=models.CASCADE, related_name="anfk" - ) - object_id = models.PositiveIntegerField() - content_object = GenericForeignKey("another_fk", "object_id") - - class Meta: - abstract = True - - comment_field = SomeModel._meta.get_field("some_fk") - self.assertEqual( - comment_field.check(), - [ - Error( - "Field specifies unsupported on_delete=DB_CASCADE on model " - "declaring a GenericForeignKey.", - hint="Change the on_delete rule to a non DB_* method", - obj=comment_field, - id="fields.E322", - ) - ], - ) - - photo_field = SomeAnotherModel._meta.get_field("another_fk") - self.assertEqual( - photo_field.check(), - [], - ) - - def test_check_db_default_foreign_key(self): - class DBDefaultsPK(models.Model): - language_code = models.CharField( - primary_key=True, max_length=2, db_default="en" - ) - - class DBDefaultsFK(models.Model): - language_code = models.ForeignKey( - DBDefaultsPK, on_delete=models.DB_SET_DEFAULT - ) - - class Meta: - abstract = True - - fk_field = DBDefaultsFK._meta.get_field("language_code") - self.assertEqual( - fk_field.check(), - [ - 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=fk_field, - id="fields.E324", - ) - ], - ) diff --git a/tests/delete/models.py b/tests/delete/models.py index 4b627712bb65..a32c32f749d7 100644 --- a/tests/delete/models.py +++ b/tests/delete/models.py @@ -241,3 +241,112 @@ class GenericDeleteBottomParent(models.Model): generic_delete_bottom = models.ForeignKey( GenericDeleteBottom, on_delete=models.CASCADE ) + + +class Foo(models.Model): + """Initial model named Foo""" + + +class Bar(models.Model): + """First level foreignkey child for Foo + Implemented using database level cascading""" + + foo = models.ForeignKey( + Foo, + on_delete=models.DB_CASCADE, + ) + + +class Baz(models.Model): + """Second level foreignkey child for Foo + Implemented using in DB cascading""" + + bar = models.ForeignKey( + Bar, + on_delete=models.DB_CASCADE, + ) + + +class RestrictBar(models.Model): + """First level child of foo with cascading set to restrict""" + + foo = models.ForeignKey( + Foo, + on_delete=models.DB_RESTRICT, + ) + + +class RestrictBaz(models.Model): + """Second level child of foo with cascading set to restrict""" + + bar = models.ForeignKey( + Bar, + on_delete=models.DB_RESTRICT, + ) + + +class SetNullBar(models.Model): + """First level child of foo with cascading set to null""" + + foo = models.ForeignKey( + Foo, + on_delete=models.DB_SET_NULL, + null=True, + ) + another_field = models.CharField(max_length=20) + + +class SetNullBaz(models.Model): + """Second level child of foo with cascading set to null""" + + bar = models.ForeignKey( + Bar, + on_delete=models.DB_SET_NULL, + null=True, + ) + another_field = models.CharField(max_length=20) + + +class AnotherSetNullBaz(models.Model): + """Second level child of foo with cascading set to null""" + + setnullbar = models.ForeignKey( + SetNullBar, + on_delete=models.DB_SET_NULL, + null=True, + ) + another_field = models.CharField(max_length=20) + + +class DBLevelChild(Foo): + grandparent_ptr = models.OneToOneField( + Foo, primary_key=True, parent_link=True, on_delete=models.DB_RESTRICT + ) + + +class NormalParent(Foo): + grandparent_ptr = models.OneToOneField( + Foo, primary_key=True, parent_link=True, on_delete=models.DB_CASCADE + ) + + +class DiamondParent(Foo): + gp_ptr = models.OneToOneField( + Foo, primary_key=True, parent_link=True, on_delete=models.DB_CASCADE + ) + + +class DiamondChild(NormalParent, DiamondParent): + parent_ptr = models.OneToOneField( + NormalParent, primary_key=True, parent_link=True, on_delete=models.DB_CASCADE + ) + + diamondparent_ptr = models.OneToOneField( + DiamondParent, parent_link=True, on_delete=models.DB_CASCADE + ) + + +class DBDefaultsFK(models.Model): + language_code = models.ForeignKey( + Foo, db_default=1, on_delete=models.DB_SET_DEFAULT + ) diff --git a/tests/delete/tests.py b/tests/delete/tests.py index 01228631f4ba..9498bd63cb41 100644 --- a/tests/delete/tests.py +++ b/tests/delete/tests.py @@ -1,6 +1,6 @@ from math import ceil -from django.db import connection, models +from django.db import IntegrityError, connection, models, transaction from django.db.models import ProtectedError, Q, RestrictedError from django.db.models.deletion import Collector from django.db.models.sql.constants import GET_ITERATOR_CHUNK_SIZE @@ -12,12 +12,20 @@ B3, MR, A, + AnotherSetNullBaz, Avatar, B, + Bar, Base, + Baz, Child, + DBDefaultsFK, + DBLevelChild, DeleteBottom, DeleteTop, + DiamondChild, + DiamondParent, + Foo, GenericB1, GenericB2, GenericDeleteBottom, @@ -27,6 +35,7 @@ M2MFrom, M2MTo, MRNull, + NormalParent, Origin, P, Parent, @@ -34,7 +43,11 @@ RChild, RChildChild, Referrer, + RestrictBar, + RestrictBaz, S, + SetNullBar, + SetNullBaz, T, User, create_a, @@ -800,3 +813,155 @@ def test_fast_delete_full_match(self): with self.assertNumQueries(1): User.objects.filter(~Q(pk__in=[]) | Q(avatar__desc="foo")).delete() self.assertFalse(User.objects.exists()) + + +class DatabaseLevelOnDeleteTests(TestCase): + def test_deletion_on_nested_cascades(self): + foo = Foo.objects.create() + bar = Bar.objects.create(foo=foo) + baz = Baz.objects.create(bar=bar) + + foo.delete() + + with self.assertRaises(Bar.DoesNotExist): + bar.refresh_from_db() + + with self.assertRaises(Baz.DoesNotExist): + baz.refresh_from_db() + + def test_restricted_deletion(self): + foo = Foo.objects.create() + RestrictBar.objects.create(foo=foo) + + with self.assertRaises(IntegrityError): + foo.delete() + + def test_restricted_deletion_by_cascade(self): + foo = Foo.objects.create() + bar = Bar.objects.create(foo=foo) + RestrictBaz.objects.create(bar=bar) + with self.assertRaises(IntegrityError): + foo.delete() + + def test_deletion_on_set_null(self): + foo = Foo.objects.create() + bar = SetNullBar.objects.create(foo=foo, another_field="Some Value") + foo.delete() + orphan_bar = SetNullBar.objects.get(pk=bar.pk) + self.assertEqual(bar.pk, orphan_bar.pk) + self.assertEqual(bar.another_field, orphan_bar.another_field) + self.assertNotEqual(bar.foo, orphan_bar.foo) + self.assertIsNone(orphan_bar.foo) + + def test_set_null_on_cascade_deletion(self): + foo = Foo.objects.create() + bar = Bar.objects.create(foo=foo) + baz = SetNullBaz.objects.create(bar=bar, another_field="Some Value") + foo.delete() + orphan_baz = SetNullBaz.objects.get(pk=baz.pk) + self.assertEqual(baz.pk, orphan_baz.pk) + self.assertEqual(baz.another_field, orphan_baz.another_field) + self.assertNotEqual(baz.bar, orphan_baz.bar) + self.assertIsNone(orphan_baz.bar) + + def test_nested_set_null_on_deletion(self): + foo = Foo.objects.create() + bar = SetNullBar.objects.create(foo=foo) + baz = AnotherSetNullBaz.objects.create(setnullbar=bar) + foo.delete() + + orphan_bar = SetNullBar.objects.get(pk=bar.pk) + self.assertEqual(bar.pk, orphan_bar.pk) + self.assertEqual(bar.another_field, orphan_bar.another_field) + self.assertNotEqual(bar.foo, orphan_bar.foo) + self.assertIsNone(orphan_bar.foo) + + orphan_baz = AnotherSetNullBaz.objects.get(pk=baz.pk) + self.assertEqual(baz.pk, orphan_baz.pk) + self.assertEqual(baz.another_field, orphan_baz.another_field) + self.assertEqual(baz.setnullbar, orphan_baz.setnullbar) + self.assertIsNotNone(orphan_baz.setnullbar) + + def test_foreign_key_db_default(self): + default_parent = Foo.objects.create(pk=1) + parent = Foo.objects.create(pk=2) + child1 = DBDefaultsFK.objects.create(language_code=parent) + with self.assertNumQueries(1): + parent.delete() + child1.refresh_from_db() + self.assertEqual(child1.language_code, default_parent) + + +class DatabaseLevelOnDeleteQueryAssertionTests(TestCase): + def test_queries_on_nested_cascade(self): + foo = Foo.objects.create() + + for i in range(3): + Bar.objects.create(foo=foo) + + for bar in Bar.objects.all(): + for i in range(3): + Baz.objects.create(bar=bar) + + # one is the deletion + with self.assertNumQueries(1): + foo.delete() + + def test_queries_on_nested_set_null(self): + foo = Foo.objects.create() + + for i in range(3): + SetNullBar.objects.create(foo=foo) + + for setnullbar in SetNullBar.objects.all(): + for i in range(3): + AnotherSetNullBaz.objects.create(setnullbar=setnullbar) + + # one is the deletion + with self.assertNumQueries(1): + foo.delete() + + def test_queries_on_nested_set_null_cascade(self): + foo = Foo.objects.create() + + for i in range(3): + Bar.objects.create(foo=foo) + + for bar in Bar.objects.all(): + for i in range(3): + SetNullBaz.objects.create(bar=bar) + + # one is the deletion + with self.assertNumQueries(1): + foo.delete() + + def test_queries_on_inherited_model(self): + gp = Foo.objects.create() + parent = NormalParent.objects.create(grandparent_ptr=gp) + diamond_parent = DiamondParent.objects.create(gp_ptr=gp) + + dc = DiamondChild.objects.create( + parent_ptr=parent, diamondparent_ptr=diamond_parent + ) + + with self.assertNumQueries(1): + gp.delete() + + with self.assertRaises(NormalParent.DoesNotExist): + parent.refresh_from_db() + + with self.assertRaises(DiamondParent.DoesNotExist): + diamond_parent.refresh_from_db() + + with self.assertRaises(DiamondChild.DoesNotExist): + dc.refresh_from_db() + + def test_restrict_on_inherited_model(self): + gp = Foo.objects.create() + child = DBLevelChild.objects.create(grandparent_ptr=gp) + + with transaction.atomic(): + with self.assertRaises(IntegrityError): + gp.delete() + + child.refresh_from_db() diff --git a/tests/invalid_models_tests/test_relative_fields.py b/tests/invalid_models_tests/test_relative_fields.py index e539d4e6fbfc..ce9f4d13750a 100644 --- a/tests/invalid_models_tests/test_relative_fields.py +++ b/tests/invalid_models_tests/test_relative_fields.py @@ -1,5 +1,7 @@ from unittest import mock +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType from django.core.checks import Error from django.core.checks import Warning as DjangoWarning from django.db import connection, models @@ -2039,3 +2041,174 @@ class Child(models.Model): ), ], ) + + +@isolate_apps("invalid_models_tests") +class DatabaseLevelCascadeCheckTests(SimpleTestCase): + def test_system_check_on_nested_db_with_non_db_cascading(self): + class Foo(models.Model): + pass + + class Bar(models.Model): + foo = models.ForeignKey( + Foo, + on_delete=models.DB_CASCADE, + ) + + class BadBar(models.Model): + foo = models.ForeignKey(Foo, on_delete=models.CASCADE) + + class Baz(models.Model): + """First level child""" + + bar = models.ForeignKey( + Bar, + on_delete=models.CASCADE, + ) + + baz_field = Baz._meta.get_field("bar") + related_model_status = {"model": Bar, "field": Bar._meta.get_field("foo")} + self.assertEqual( + baz_field.check(), + [ + Error( + "Using python based on_delete with database " + "level on_delete referenced model is prohibited " + f"Related field is {related_model_status.get('field')}.", + hint="Use database level cascading for foreignkeys", + obj=baz_field, + id="fields.E323", + ), + ], + ) + + bad_bar_field = BadBar._meta.get_field("foo") + self.assertEqual(bad_bar_field.check(), []) + + bar_field = Bar._meta.get_field("foo") + self.assertEqual(bar_field.check(), []) + + def test_null_condition_with_set_null_db(self): + class Foo(models.Model): + pass + + class SetNullDbNotNullModel(models.Model): + foo = models.ForeignKey( + Foo, + on_delete=models.DB_SET_NULL, + ) + + class Meta: + managed = False + + field = SetNullDbNotNullModel._meta.get_field("foo") + 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_check_on_inherited_models(self): + class GrandParent(models.Model): + pass + + class Parent(GrandParent): + pass + + class DiamondParent(GrandParent): + gp_ptr = models.OneToOneField( + GrandParent, + primary_key=True, + parent_link=True, + on_delete=models.DB_CASCADE, + ) + + class DiamondChild(Parent, DiamondParent): + parent_ptr = models.OneToOneField( + Parent, primary_key=True, parent_link=True, on_delete=models.DB_CASCADE + ) + + field = DiamondChild._meta.get_field("diamondparent_ptr") + rel_field = DiamondParent._meta.get_field("gp_ptr") + # error for having DB_CASCADE over normal cascade + self.assertEqual( + field.check(), + [ + Error( + "Using python based on_delete with database " + "level on_delete referenced model is prohibited " + f"Related field is {rel_field}.", + hint="Use database level cascading for foreignkeys", + obj=field, + id="fields.E323", + ) + ], + ) + # No error for entire django cascade + field = Parent._meta.get_field("grandparent_ptr") + self.assertEqual( + field.check(), + [], + ) + + def test_check_on_generic_foreign_key(self): + class SomeModel(models.Model): + some_fk = models.ForeignKey( + ContentType, + on_delete=models.DB_CASCADE, + related_name="ctcmnt", + ) + object_id = models.PositiveIntegerField() + content_object = GenericForeignKey("some_fk", "object_id") + + class Meta: + abstract = True + + comment_field = SomeModel._meta.get_field("some_fk") + self.assertIn( + Error( + "Field specifies unsupported on_delete=DB_CASCADE on model " + "declaring a GenericForeignKey.", + hint="Change the on_delete rule to a non DB_* method", + obj=comment_field, + id="fields.E322", + ), + comment_field.check(), + ) + + def test_check_db_default_foreign_key(self): + class DBDefaultsPK(models.Model): + language_code = models.CharField( + primary_key=True, max_length=2, db_default="en" + ) + + class DBDefaultsFK(models.Model): + language_code = models.ForeignKey( + DBDefaultsPK, on_delete=models.DB_SET_DEFAULT + ) + + class Meta: + abstract = True + + fk_field = DBDefaultsFK._meta.get_field("language_code") + self.assertEqual( + fk_field.check(), + [ + 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=fk_field, + id="fields.E324", + ) + ], + ) From 79f2bd6be513268d8de5a7ff3c619fc61416f042 Mon Sep 17 00:00:00 2001 From: Akash-Kumar-Sen Date: Fri, 25 Aug 2023 08:46:16 +0530 Subject: [PATCH 37/60] Add test for migrations --- tests/migrations/test_operations.py | 416 ++++++++++++++++++++++++++++ 1 file changed, 416 insertions(+) diff --git a/tests/migrations/test_operations.py b/tests/migrations/test_operations.py index e377e4ca6408..39c1d1b49417 100644 --- a/tests/migrations/test_operations.py +++ b/tests/migrations/test_operations.py @@ -180,6 +180,422 @@ def test_create_model_with_duplicate_manager_name(self): ], ) + def test_create_model_with_db_level_fk(self): + app_label = "test_cmwdblfk" + operations = [ + migrations.CreateModel( + "Pony", + [ + ( + "id", + models.CharField( + primary_key=True, + max_length=10, + ), + ), + ], + ) + ] + project_state = self.apply_operations(app_label, ProjectState(), operations) + # ForeignKey. + new_state = project_state.clone() + operation = migrations.CreateModel( + "Rider", + [ + ("id", models.AutoField(primary_key=True)), + ("number", models.IntegerField(default=1)), + ( + "pony_cascade", + models.ForeignKey(f"{app_label}.Pony", on_delete=models.DB_CASCADE), + ), + ( + "pony_set_null", + models.ForeignKey( + f"{app_label}.Pony", null=True, on_delete=models.DB_SET_NULL + ), + ), + ( + "pony_restrict", + models.ForeignKey( + f"{app_label}.Pony", on_delete=models.DB_RESTRICT + ), + ), + ( + "pony_default", + models.ForeignKey( + f"{app_label}.Pony", + db_default="bn", + on_delete=models.DB_SET_DEFAULT, + ), + ), + ], + ) + operation.state_forwards(app_label, new_state) + self.assertTableNotExists(f"{app_label}_rider") + with connection.schema_editor() as editor: + operation.database_forwards(app_label, editor, project_state, new_state) + self.assertTableExists(f"{app_label}_rider") + self.assertColumnExists(f"{app_label}_rider", "pony_cascade_id") + self.assertColumnExists(f"{app_label}_rider", "pony_set_null_id") + self.assertColumnExists(f"{app_label}_rider", "pony_restrict_id") + self.assertColumnExists(f"{app_label}_rider", "pony_default_id") + + def test_alter_field_with_db_level_fk(self): + app_label = "test_alterfwdblfk" + non_db_cascade_options = { + "cascade": [ + models.CASCADE, + models.ForeignKey( + f"{app_label}.Pony_cascade", on_delete=models.CASCADE + ), + ], + "set_null": [ + models.SET_NULL, + models.ForeignKey( + f"{app_label}.Pony_set_null", null=True, on_delete=models.SET_NULL + ), + ], + "restrict": [ + models.RESTRICT, + models.ForeignKey( + f"{app_label}.Pony_restrict", on_delete=models.RESTRICT + ), + ], + "set_default": [ + models.SET_DEFAULT, + models.ForeignKey( + f"{app_label}.Pony_set_default", + default="bn", + on_delete=models.SET_DEFAULT, + ), + ], + } + for on_delete_type in non_db_cascade_options.keys(): + db_level_cascade_options = { + "cascade": [ + models.DB_CASCADE, + models.ForeignKey( + f"{app_label}.Pony_{on_delete_type}", + on_delete=models.DB_CASCADE, + ), + ], + "set_null": [ + models.DB_SET_NULL, + models.ForeignKey( + f"{app_label}.Pony_{on_delete_type}", + null=True, + on_delete=models.DB_SET_NULL, + ), + ], + "restrict": [ + models.DB_RESTRICT, + models.ForeignKey( + f"{app_label}.Pony_{on_delete_type}", + on_delete=models.DB_RESTRICT, + ), + ], + "set_default": [ + models.DB_SET_DEFAULT, + models.ForeignKey( + f"{app_label}.Pony_{on_delete_type}", + db_default="bn", + on_delete=models.DB_SET_DEFAULT, + ), + ], + } + operations = [ + migrations.CreateModel( + f"Pony_{on_delete_type}", + [ + ( + "id", + models.CharField( + primary_key=True, + max_length=10, + ), + ), + ], + ), + migrations.CreateModel( + f"Rider_{on_delete_type}", + [ + ("id", models.AutoField(primary_key=True)), + ("number", models.IntegerField(default=1)), + ( + f"pony_{on_delete_type}", + non_db_cascade_options[on_delete_type][1], + ), + ], + ), + ] + project_state = self.apply_operations(app_label, ProjectState(), operations) + # ForeignKey. + for db_level_on_delete_type in db_level_cascade_options.keys(): + Rider = project_state.apps.get_model( + app_label, f"Rider_{on_delete_type}" + ) + self.assertEqual( + Rider._meta.get_field( + f"pony_{on_delete_type}" + ).remote_field.on_delete, + non_db_cascade_options[on_delete_type][0], + ) + + operation = migrations.AlterField( + f"Rider_{on_delete_type}", + f"pony_{on_delete_type}", + db_level_cascade_options[db_level_on_delete_type][1], + ) + new_state = project_state.clone() + operation.state_forwards(app_label, new_state) + + with connection.schema_editor() as editor: + operation.database_forwards( + app_label, editor, project_state, new_state + ) + + Rider = new_state.apps.get_model(app_label, f"Rider_{on_delete_type}") + self.assertEqual( + Rider._meta.get_field( + f"pony_{on_delete_type}" + ).remote_field.on_delete, + db_level_cascade_options[db_level_on_delete_type][0], + ) + + with connection.schema_editor() as editor: + operation.database_backwards( + app_label, editor, new_state, project_state + ) + + Rider = project_state.apps.get_model( + app_label, f"Rider_{on_delete_type}" + ) + self.assertEqual( + Rider._meta.get_field( + f"pony_{on_delete_type}" + ).remote_field.on_delete, + non_db_cascade_options[on_delete_type][0], + ) + + def test_alter_field_among_db_level_fk(self): + app_label = "test_alterfadblfk" + db_cascade_options_primary = { + "cascade": [ + models.DB_CASCADE, + models.ForeignKey( + f"{app_label}.Pony_cascade", on_delete=models.DB_CASCADE + ), + ], + "set_null": [ + models.DB_SET_NULL, + models.ForeignKey( + f"{app_label}.Pony_set_null", + null=True, + on_delete=models.DB_SET_NULL, + ), + ], + "restrict": [ + models.DB_RESTRICT, + models.ForeignKey( + f"{app_label}.Pony_restrict", on_delete=models.DB_RESTRICT + ), + ], + "set_default": [ + models.DB_SET_DEFAULT, + models.ForeignKey( + f"{app_label}.Pony_set_default", + default="bn", + on_delete=models.DB_SET_DEFAULT, + ), + ], + } + for primary_on_delete_type in db_cascade_options_primary.keys(): + db_level_cascade_options_secondary = { + "cascade": [ + models.DB_CASCADE, + models.ForeignKey( + f"{app_label}.Pony_{primary_on_delete_type}", + on_delete=models.DB_CASCADE, + ), + ], + "set_null": [ + models.DB_SET_NULL, + models.ForeignKey( + f"{app_label}.Pony_{primary_on_delete_type}", + null=True, + on_delete=models.DB_SET_NULL, + ), + ], + "restrict": [ + models.DB_RESTRICT, + models.ForeignKey( + f"{app_label}.Pony_{primary_on_delete_type}", + on_delete=models.DB_RESTRICT, + ), + ], + "set_default": [ + models.DB_SET_DEFAULT, + models.ForeignKey( + f"{app_label}.Pony_{primary_on_delete_type}", + db_default="bn", + on_delete=models.DB_SET_DEFAULT, + ), + ], + } + operations = [ + migrations.CreateModel( + f"Pony_{primary_on_delete_type}", + [ + ( + "id", + models.CharField( + primary_key=True, + max_length=10, + ), + ), + ], + ), + migrations.CreateModel( + f"Rider_{primary_on_delete_type}", + [ + ("id", models.AutoField(primary_key=True)), + ("number", models.IntegerField(default=1)), + ( + f"pony_{primary_on_delete_type}", + db_cascade_options_primary[primary_on_delete_type][1], + ), + ], + ), + ] + project_state = self.apply_operations(app_label, ProjectState(), operations) + # ForeignKey. + for secondary_on_delete_type in db_level_cascade_options_secondary.keys(): + if primary_on_delete_type == secondary_on_delete_type: + continue + Rider = project_state.apps.get_model( + app_label, f"Rider_{primary_on_delete_type}" + ) + self.assertEqual( + Rider._meta.get_field( + f"pony_{primary_on_delete_type}" + ).remote_field.on_delete, + db_cascade_options_primary[primary_on_delete_type][0], + ) + + operation = migrations.AlterField( + f"Rider_{primary_on_delete_type}", + f"pony_{primary_on_delete_type}", + db_level_cascade_options_secondary[secondary_on_delete_type][1], + ) + new_state = project_state.clone() + operation.state_forwards(app_label, new_state) + + with connection.schema_editor() as editor: + operation.database_forwards( + app_label, editor, project_state, new_state + ) + + Rider = new_state.apps.get_model( + app_label, f"Rider_{primary_on_delete_type}" + ) + self.assertEqual( + Rider._meta.get_field( + f"pony_{primary_on_delete_type}" + ).remote_field.on_delete, + db_level_cascade_options_secondary[secondary_on_delete_type][0], + ) + + with connection.schema_editor() as editor: + operation.database_backwards( + app_label, editor, new_state, project_state + ) + + Rider = project_state.apps.get_model( + app_label, f"Rider_{primary_on_delete_type}" + ) + self.assertEqual( + Rider._meta.get_field( + f"pony_{primary_on_delete_type}" + ).remote_field.on_delete, + db_cascade_options_primary[primary_on_delete_type][0], + ) + + def test_add_field_db_level_fk(self): + app_label = "test_alterfadblfk" + db_cascade_options_primary = { + "cascade": [ + models.DB_CASCADE, + models.ForeignKey(f"{app_label}.Pony", on_delete=models.DB_CASCADE), + ], + "set_null": [ + models.DB_SET_NULL, + models.ForeignKey( + f"{app_label}.Pony", + null=True, + on_delete=models.DB_SET_NULL, + ), + ], + "restrict": [ + models.DB_RESTRICT, + models.ForeignKey(f"{app_label}.Pony", on_delete=models.DB_RESTRICT), + ], + "set_default": [ + models.DB_SET_DEFAULT, + models.ForeignKey( + f"{app_label}.Pony", + default="bn", + on_delete=models.DB_SET_DEFAULT, + ), + ], + } + operations = [ + migrations.CreateModel( + "Pony", + [ + ( + "id", + models.CharField( + primary_key=True, + max_length=10, + ), + ), + ], + ), + migrations.CreateModel( + "Rider", + [ + ("id", models.AutoField(primary_key=True)), + ("number", models.IntegerField(default=1)), + ], + ), + ] + project_state = self.apply_operations(app_label, ProjectState(), operations) + for db_cascade_option in db_cascade_options_primary.keys(): + operation = migrations.AddField( + "Rider", + f"pony_{db_cascade_option}", + db_cascade_options_primary[db_cascade_option][1], + ) + new_state = project_state.clone() + operation.state_forwards(app_label, new_state) + self.assertColumnNotExists( + f"{app_label}_rider", f"pony_{db_cascade_option}_id" + ) + + with connection.schema_editor() as editor: + operation.database_forwards(app_label, editor, project_state, new_state) + self.assertColumnExists( + f"{app_label}_rider", f"pony_{db_cascade_option}_id" + ) + + with connection.schema_editor() as editor: + operation.database_backwards( + app_label, editor, new_state, project_state + ) + self.assertColumnNotExists( + f"{app_label}_rider", f"pony_{db_cascade_option}_id" + ) + def test_create_model_with_unique_after(self): """ Tests the CreateModel operation directly followed by an From a8789ab60cafc1576080bd5da200324b365659ac Mon Sep 17 00:00:00 2001 From: Akash-Kumar-Sen Date: Fri, 25 Aug 2023 18:05:49 +0530 Subject: [PATCH 38/60] Add tests for serializers --- django/db/migrations/serializer.py | 2 +- tests/migrations/test_writer.py | 24 +++++++++++++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/django/db/migrations/serializer.py b/django/db/migrations/serializer.py index 2967217b36a3..f6624cb7989c 100644 --- a/django/db/migrations/serializer.py +++ b/django/db/migrations/serializer.py @@ -260,7 +260,7 @@ def serialize(self): class DatabaseOnDeleteSerializer(BaseSerializer): def serialize(self): - return f"models.{self.value.name}", {} + return f"models.{self.value.__name__}", {} class RegexSerializer(BaseSerializer): diff --git a/tests/migrations/test_writer.py b/tests/migrations/test_writer.py index 33b52bd5385d..de215993652a 100644 --- a/tests/migrations/test_writer.py +++ b/tests/migrations/test_writer.py @@ -20,7 +20,7 @@ from django.conf import SettingsReference, settings from django.core.validators import EmailValidator, RegexValidator from django.db import migrations, models -from django.db.migrations.serializer import BaseSerializer +from django.db.migrations.serializer import BaseSerializer, serializer_factory from django.db.migrations.writer import MigrationWriter, OperationWriter from django.test import SimpleTestCase from django.utils.deconstruct import deconstructible @@ -1001,3 +1001,25 @@ def test_register_non_serializer(self): ValueError, "'TestModel1' must inherit from 'BaseSerializer'." ): MigrationWriter.register_serializer(complex, TestModel1) + + def test_database_on_delete_serializer_value(self): + db_level_on_delete_options = [ + models.DB_CASCADE, + models.DB_SET_DEFAULT, + models.DB_SET_NULL, + models.DB_RESTRICT, + ] + for option in db_level_on_delete_options: + serialized_data = MigrationWriter.serialize(option)[0] + self.assertEqual(serialized_data, f"models.{option.__name__}") + + def test_database_on_delete_serializer_value_with_field(self): + db_level_on_delete_options = [ + models.ForeignKey("test", on_delete=models.DB_CASCADE), + models.ForeignKey("test", null=True, on_delete=models.DB_SET_NULL), + models.ForeignKey("test", on_delete=models.DB_RESTRICT), + models.ForeignKey("test", db_default="bn", on_delete=models.DB_SET_DEFAULT), + ] + for option in db_level_on_delete_options: + serialized_data = MigrationWriter.serialize(option) + self.assertEqual(serialized_data, serializer_factory(option).serialize()) From b25b63ec106c8331d4a553bf3611af888813ab1a Mon Sep 17 00:00:00 2001 From: Akash-Kumar-Sen Date: Wed, 6 Sep 2023 21:31:27 +0530 Subject: [PATCH 39/60] Deprecation of ON DELETE SET DEFAULT in MySQL --- django/db/backends/base/features.py | 3 +++ django/db/backends/mysql/features.py | 3 +++ docs/ref/models/fields.txt | 15 +++++++++++++++ tests/delete/models.py | 5 ++++- tests/delete/tests.py | 1 + 5 files changed, 26 insertions(+), 1 deletion(-) diff --git a/django/db/backends/base/features.py b/django/db/backends/base/features.py index 11dd0791109c..ad69dc9e3ed4 100644 --- a/django/db/backends/base/features.py +++ b/django/db/backends/base/features.py @@ -132,6 +132,9 @@ class BaseDatabaseFeatures: # which can't do it for MyISAM tables can_introspect_foreign_keys = True + # Features related to database level on delete + has_on_delete_db_default = True + # Map fields which some backends may not be able to differentiate to the # field it's introspected as. introspected_field_types = { diff --git a/django/db/backends/mysql/features.py b/django/db/backends/mysql/features.py index 0bb0f91f5527..9f788967d836 100644 --- a/django/db/backends/mysql/features.py +++ b/django/db/backends/mysql/features.py @@ -57,6 +57,9 @@ class DatabaseFeatures(BaseDatabaseFeatures): order_by_nulls_first = True supports_logical_xor = True + # Features related to database level on delete + has_on_delete_db_default = False + @cached_property def minimum_database_version(self): if self.connection.mysql_is_mariadb: diff --git a/docs/ref/models/fields.txt b/docs/ref/models/fields.txt index 17249bf9988d..e233a1b8e0e6 100644 --- a/docs/ref/models/fields.txt +++ b/docs/ref/models/fields.txt @@ -1775,6 +1775,21 @@ any custom methods or send any signals. table is deleted, the foreign key values in the referencing table will be updated to their :attr:`Field.db_default` values. +.. admonition:: Support for DB_SET_DEFAULT in MySQL + + The ``on_delete=DB_SET_DEFAULT`` attribute is not supported in mysql + database because Both ``InnoDB`` and ``NDB`` mysql engines reject + table definitions containing ``ON DELETE SET DEFAULT`` clauses and for + storage engines that support foreign keys, MySQL rejects any ``INSERT`` + or ``UPDATE`` operation that attempts to create a foreign key value in + a child table if there is no matching candidate key value in the + parent table. + + For more details, visit `MySQL foreignkey documentation`_. + +.. _MySQL foreignkey documentation: https://dev.mysql.com/doc/refman/8.0/en/create-table-foreign-keys.html + + .. attribute:: ForeignKey.limit_choices_to Sets a limit to the available choices for this field when this field is diff --git a/tests/delete/models.py b/tests/delete/models.py index a32c32f749d7..21e5885a6b8b 100644 --- a/tests/delete/models.py +++ b/tests/delete/models.py @@ -1,6 +1,6 @@ from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation from django.contrib.contenttypes.models import ContentType -from django.db import models +from django.db import connection, models class P(models.Model): @@ -350,3 +350,6 @@ class DBDefaultsFK(models.Model): language_code = models.ForeignKey( Foo, db_default=1, on_delete=models.DB_SET_DEFAULT ) + + class Meta: + managed = getattr(connection.features, "has_on_delete_db_default", False) diff --git a/tests/delete/tests.py b/tests/delete/tests.py index 9498bd63cb41..bea88852c2d4 100644 --- a/tests/delete/tests.py +++ b/tests/delete/tests.py @@ -882,6 +882,7 @@ def test_nested_set_null_on_deletion(self): self.assertEqual(baz.setnullbar, orphan_baz.setnullbar) self.assertIsNotNone(orphan_baz.setnullbar) + @skipUnlessDBFeature("has_on_delete_db_default") def test_foreign_key_db_default(self): default_parent = Foo.objects.create(pk=1) parent = Foo.objects.create(pk=2) From 9af639a25c3f0b41b3f375dd921e9f503fe5a811 Mon Sep 17 00:00:00 2001 From: Akash-Kumar-Sen Date: Wed, 6 Sep 2023 22:49:29 +0530 Subject: [PATCH 40/60] Adjust tests --- tests/migrations/test_operations.py | 68 +++++++++++++++++++---------- 1 file changed, 44 insertions(+), 24 deletions(-) diff --git a/tests/migrations/test_operations.py b/tests/migrations/test_operations.py index 0ed422106dca..8469eb150f29 100644 --- a/tests/migrations/test_operations.py +++ b/tests/migrations/test_operations.py @@ -199,27 +199,26 @@ def test_create_model_with_db_level_fk(self): project_state = self.apply_operations(app_label, ProjectState(), operations) # ForeignKey. new_state = project_state.clone() - operation = migrations.CreateModel( - "Rider", - [ - ("id", models.AutoField(primary_key=True)), - ("number", models.IntegerField(default=1)), - ( - "pony_cascade", - models.ForeignKey(f"{app_label}.Pony", on_delete=models.DB_CASCADE), - ), - ( - "pony_set_null", - models.ForeignKey( - f"{app_label}.Pony", null=True, on_delete=models.DB_SET_NULL - ), - ), - ( - "pony_restrict", - models.ForeignKey( - f"{app_label}.Pony", on_delete=models.DB_RESTRICT - ), + operation_list = [ + ("id", models.AutoField(primary_key=True)), + ("number", models.IntegerField(default=1)), + ( + "pony_cascade", + models.ForeignKey(f"{app_label}.Pony", on_delete=models.DB_CASCADE), + ), + ( + "pony_set_null", + models.ForeignKey( + f"{app_label}.Pony", null=True, on_delete=models.DB_SET_NULL ), + ), + ( + "pony_restrict", + models.ForeignKey(f"{app_label}.Pony", on_delete=models.DB_RESTRICT), + ), + ] + if connection.features.has_on_delete_db_default: + operation_list.append( ( "pony_default", models.ForeignKey( @@ -227,9 +226,9 @@ def test_create_model_with_db_level_fk(self): db_default="bn", on_delete=models.DB_SET_DEFAULT, ), - ), - ], - ) + ) + ) + operation = migrations.CreateModel("Rider", operation_list) operation.state_forwards(app_label, new_state) self.assertTableNotExists(f"{app_label}_rider") with connection.schema_editor() as editor: @@ -238,7 +237,8 @@ def test_create_model_with_db_level_fk(self): self.assertColumnExists(f"{app_label}_rider", "pony_cascade_id") self.assertColumnExists(f"{app_label}_rider", "pony_set_null_id") self.assertColumnExists(f"{app_label}_rider", "pony_restrict_id") - self.assertColumnExists(f"{app_label}_rider", "pony_default_id") + if connection.features.has_on_delete_db_default: + self.assertColumnExists(f"{app_label}_rider", "pony_default_id") def test_alter_field_with_db_level_fk(self): app_label = "test_alterfwdblfk" @@ -331,6 +331,11 @@ def test_alter_field_with_db_level_fk(self): project_state = self.apply_operations(app_label, ProjectState(), operations) # ForeignKey. for db_level_on_delete_type in db_level_cascade_options.keys(): + if ( + db_level_on_delete_type == "set_default" + and not connection.features.has_on_delete_db_default + ): + continue Rider = project_state.apps.get_model( app_label, f"Rider_{on_delete_type}" ) @@ -410,6 +415,11 @@ def test_alter_field_among_db_level_fk(self): ], } for primary_on_delete_type in db_cascade_options_primary.keys(): + if ( + primary_on_delete_type == "set_default" + and not connection.features.has_on_delete_db_default + ): + continue db_level_cascade_options_secondary = { "cascade": [ models.DB_CASCADE, @@ -472,6 +482,11 @@ def test_alter_field_among_db_level_fk(self): for secondary_on_delete_type in db_level_cascade_options_secondary.keys(): if primary_on_delete_type == secondary_on_delete_type: continue + if ( + secondary_on_delete_type == "set_default" + and not connection.features.has_on_delete_db_default + ): + continue Rider = project_state.apps.get_model( app_label, f"Rider_{primary_on_delete_type}" ) @@ -571,6 +586,11 @@ def test_add_field_db_level_fk(self): ] project_state = self.apply_operations(app_label, ProjectState(), operations) for db_cascade_option in db_cascade_options_primary.keys(): + if ( + db_cascade_option == "set_default" + and not connection.features.has_on_delete_db_default + ): + continue operation = migrations.AddField( "Rider", f"pony_{db_cascade_option}", From 0f0771915265c3711331887f08247b6527cf90a6 Mon Sep 17 00:00:00 2001 From: Akash-Kumar-Sen Date: Thu, 7 Sep 2023 17:33:51 +0530 Subject: [PATCH 41/60] DB Level on delete PR with migration tests Update tests for migrations Update the parent model logic Date: Tue, 10 Oct 2023 14:01:56 +0530 Subject: [PATCH 42/60] Move the release notes to 5.1 --- docs/releases/5.1.txt | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 docs/releases/5.1.txt diff --git a/docs/releases/5.1.txt b/docs/releases/5.1.txt new file mode 100644 index 000000000000..a9683d3ae2ce --- /dev/null +++ b/docs/releases/5.1.txt @@ -0,0 +1,13 @@ + +Database-level values for ``ForeignKey.on_delete`` +-------------------------------------------------- + +:attr:`ForeignKey.on_delete ` now +supports four extra values to specify database-level actions: + +* ``DB_CASCADE`` - deletes the referring object. +* ``DB_RESTRICT`` - prevents deletion of referred-to objects. +* ``DB_SET_NULL`` - sets the referring foreign key to SQL ``NULL``. +* ``DB_SET_DEFAULT`` - the value of the foreign key column in the referencing + table will be set to its ``db_default`` value. + From f4daa14cb3724596620308f792bf5a7e03c00485 Mon Sep 17 00:00:00 2001 From: Akash-Kumar-Sen Date: Tue, 10 Oct 2023 14:07:00 +0530 Subject: [PATCH 43/60] Update the release notes --- docs/releases/5.0.txt | 203 +++++++++------------------ docs/releases/5.1.txt | 314 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 382 insertions(+), 135 deletions(-) diff --git a/docs/releases/5.0.txt b/docs/releases/5.0.txt index ede7965fb986..cb70b0640995 100644 --- a/docs/releases/5.0.txt +++ b/docs/releases/5.0.txt @@ -129,27 +129,20 @@ sets a database-computed default value. For example:: created = models.DateTimeField(db_default=Now()) circumference = models.FloatField(db_default=2 * Pi()) - -Database-level values for ``ForeignKey.on_delete`` --------------------------------------------------- - -:attr:`ForeignKey.on_delete ` now -supports four extra values to specify database-level actions: - -* ``DB_CASCADE`` - deletes the referring object. -* ``DB_RESTRICT`` - prevents deletion of referred-to objects. -* ``DB_SET_NULL`` - sets the referring foreign key to SQL ``NULL``. -* ``DB_SET_DEFAULT`` - the value of the foreign key column in the referencing - table will be set to its ``db_default`` value. - - Database generated model field ------------------------------ The new :class:`~django.db.models.GeneratedField` allows creation of database generated columns. This field can be used on all supported database backends -to create a field that is always computed from other fields. +to create a field that is always computed from other fields. For example:: + from django.db import models + from django.db.models import F + + + class Square(models.Model): + side = models.IntegerField() + area = models.GeneratedField(expression=F("side") * F("side"), db_persist=True) More options for declaring field choices ---------------------------------------- @@ -172,14 +165,14 @@ form:: ] - class Winners(models.Model): + class Winner(models.Model): name = models.CharField(...) medal = models.CharField(..., choices=Medal.choices) sport = models.CharField(..., choices=SPORT_CHOICES) -Django 5.0 supports providing a mapping instead of an iterable, and also no -longer requires ``.choices`` to be used directly to expand :ref:`enumeration -types `:: +Django 5.0 adds support for accepting a mapping or a callable instead of an +iterable, and also no longer requires ``.choices`` to be used directly to +expand :ref:`enumeration types `:: from django.db import models @@ -192,13 +185,20 @@ types `:: } - class Winners(models.Model): + def get_scores(): + return [(i, str(i)) for i in range(10)] + + + class Winner(models.Model): name = models.CharField(...) medal = models.CharField(..., choices=Medal) # Using `.choices` not required. sport = models.CharField(..., choices=SPORT_CHOICES) + score = models.IntegerField(choices=get_scores) # A callable is allowed. Under the hood the provided ``choices`` are normalized into a list of 2-tuples -as the canonical form whenever the ``choices`` value is updated. +as the canonical form whenever the ``choices`` value is updated. For more +information, please check the :ref:`model field reference on choices +`. Minor features -------------- @@ -219,10 +219,11 @@ Minor features * The new :meth:`.AdminSite.get_model_admin` method returns an admin class for the given model class. -:mod:`django.contrib.admindocs` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +* Properties in :attr:`.ModelAdmin.list_display` now support ``boolean`` + attribute. + +* jQuery is upgraded from version 3.6.4 to 3.7.1. -* ... :mod:`django.contrib.auth` ~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -246,7 +247,9 @@ Minor features :mod:`django.contrib.contenttypes` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -* ... +* :meth:`.QuerySet.prefetch_related` now supports prefetching + :class:`~django.contrib.contenttypes.fields.GenericForeignKey` with + non-homogeneous set of results. :mod:`django.contrib.gis` ~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -259,9 +262,7 @@ Minor features * :ref:`GIS aggregates ` now support the ``filter`` argument. -* Added support for GDAL 3.7. - -* Added support for GEOS 3.12. +* Support for GDAL 3.7 and GEOS 3.12 is added. * The new :meth:`.GEOSGeometry.equals_identical` method allows point-wise equivalence checking of geometries. @@ -281,36 +282,6 @@ Minor features customizing the ``code`` of ``ValidationError`` raised during :ref:`model validation `. -:mod:`django.contrib.redirects` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -* ... - -:mod:`django.contrib.sessions` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -* ... - -:mod:`django.contrib.sitemaps` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -* ... - -:mod:`django.contrib.sites` -~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -* ... - -:mod:`django.contrib.staticfiles` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -* ... - -:mod:`django.contrib.syndication` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -* ... - Asynchronous views ~~~~~~~~~~~~~~~~~~ @@ -318,16 +289,6 @@ Asynchronous views perform any necessary cleanup if a client disconnects before the response is generated. See :ref:`async-handling-disconnect` for more details. -Cache -~~~~~ - -* ... - -CSRF -~~~~ - -* ... - Decorators ~~~~~~~~~~ @@ -337,9 +298,14 @@ Decorators * :func:`~django.views.decorators.cache.never_cache` * :func:`~django.views.decorators.common.no_append_slash` * :func:`~django.views.decorators.csrf.csrf_exempt` + * :func:`~django.views.decorators.csrf.csrf_protect` + * :func:`~django.views.decorators.csrf.ensure_csrf_cookie` + * :func:`~django.views.decorators.csrf.requires_csrf_token` * :func:`~django.views.decorators.debug.sensitive_variables` * :func:`~django.views.decorators.debug.sensitive_post_parameters` + * :func:`~django.views.decorators.gzip.gzip_page` * :func:`~django.views.decorators.http.condition` + * ``conditional_page()`` * :func:`~django.views.decorators.http.etag` * :func:`~django.views.decorators.http.last_modified` * :func:`~django.views.decorators.http.require_http_methods` @@ -352,11 +318,6 @@ Decorators * ``xframe_options_sameorigin()`` * ``xframe_options_exempt()`` -Email -~~~~~ - -* ... - Error Reporting ~~~~~~~~~~~~~~~ @@ -367,12 +328,8 @@ Error Reporting File Storage ~~~~~~~~~~~~ -* ... - -File Uploads -~~~~~~~~~~~~ - -* ... +* :meth:`.File.open` now passes all positional (``*args``) and keyword + arguments (``**kwargs``) to Python's built-in :func:`python:open`. Forms ~~~~~ @@ -380,37 +337,23 @@ Forms * The new ``assume_scheme`` argument for :class:`~django.forms.URLField` allows specifying a default URL scheme. -* In order to improve accessibility and enable screen readers to associate form - fields with their help text, the form field now includes the - ``aria-describedby`` HTML attribute. - -* In order to improve accessibility, the invalid form field now includes the - ``aria-invalid="true"`` HTML attribute. +* In order to improve accessibility, the following changes are made: -Generic Views -~~~~~~~~~~~~~ - -* ... + * Form fields now include the ``aria-describedby`` HTML attribute to enable + screen readers to associate form fields with their help text. + * Invalid form fields now include the ``aria-invalid="true"`` HTML attribute. Internationalization ~~~~~~~~~~~~~~~~~~~~ -* Added support and translations for the Uyghur language. - -Logging -~~~~~~~ - -* ... - -Management Commands -~~~~~~~~~~~~~~~~~~~ - -* ... +* Support and translations for the Uyghur language are now available. Migrations ~~~~~~~~~~ -* ... +* Serialization of functions decorated with :func:`functools.cache` or + :func:`functools.lru_cache` is now supported without the need to write a + custom serializer. Models ~~~~~~ @@ -461,21 +404,6 @@ Pagination * The new :attr:`django.core.paginator.Paginator.error_messages` argument allows customizing the error messages raised by :meth:`.Paginator.page`. -Requests and Responses -~~~~~~~~~~~~~~~~~~~~~~ - -* ... - -Security -~~~~~~~~ - -* ... - -Serialization -~~~~~~~~~~~~~ - -* ... - Signals ~~~~~~~ @@ -503,16 +431,6 @@ Tests * The new :option:`test --durations` option allows showing the duration of the slowest tests on Python 3.12+. -URLs -~~~~ - -* ... - -Utilities -~~~~~~~~~ - -* ... - Validators ~~~~~~~~~~ @@ -538,16 +456,10 @@ backends. ``False`` if the database doesn't support the ``DEFAULT`` keyword in ``INSERT`` queries. -* ``DatabaseFeatures.supports_default_keyword_in_bulk insert`` should be set to +* ``DatabaseFeatures.supports_default_keyword_in_bulk_insert`` should be set to ``False`` if the database doesn't support the ``DEFAULT`` keyword in bulk ``INSERT`` queries. -Dropped support for MySQL < 8.0.11 ----------------------------------- - -Support for pre-releases of MySQL 8.0.x series is removed. Django 5.0 supports -MySQL 8.0.11 and higher. - :mod:`django.contrib.gis` ------------------------- @@ -564,6 +476,12 @@ MySQL 8.0.11 and higher. * The ``django.contrib.sitemaps.SitemapNotFound`` exception class is removed. +Dropped support for MySQL < 8.0.11 +---------------------------------- + +Support for pre-releases of MySQL 8.0.x series is removed. Django 5.0 supports +MySQL 8.0.11 and higher. + Using ``create_defaults__exact`` may now be required with ``QuerySet.update_or_create()`` ----------------------------------------------------------------------------------------- @@ -629,6 +547,10 @@ Miscellaneous a page. Having two ``

`` elements was confusing and the site header wasn't helpful as it is repeated on all pages. +* In order to improve accessibility, the admin's main content area and header + content area are now rendered in a ``
`` and ``
`` tag instead of + ``
``. + * On databases without native support for the SQL ``XOR`` operator, ``^`` as the exclusive or (``XOR``) operator now returns rows that are matched by an odd number of operands rather than exactly one operand. This is consistent @@ -669,7 +591,7 @@ Miscellaneous * The ``DjangoDivFormRenderer`` and ``Jinja2DivFormRenderer`` transitional form renderers are deprecated. -* Passing positional arguments ``name`` and ``violation_error_message`` to +* Passing positional arguments ``name`` and ``violation_error_message`` to :class:`~django.db.models.BaseConstraint` is deprecated in favor of keyword-only arguments. @@ -699,6 +621,17 @@ Miscellaneous ``BuiltinLookup.process_lhs()`` will no longer call ``field_cast_sql()``. Third-party database backends should implement ``lookup_cast()`` instead. +* The ``django.db.models.enums.ChoicesMeta`` metaclass is renamed to + ``ChoicesType``. + +* The ``Prefetch.get_current_queryset()`` method is deprecated. + +* The ``get_prefetch_queryset()`` method of related managers and descriptors + is deprecated. Starting with Django 6.0, ``get_prefetcher()`` and + ``prefetch_related_objects()`` will no longer fallback to + ``get_prefetch_queryset()``. Subclasses should implement + ``get_prefetch_querysets()`` instead. + .. _`oracledb`: https://oracle.github.io/python-oracledb/ Features removed in 5.0 @@ -805,4 +738,4 @@ to remove usage of these features. * Passing ``nulls_first=False`` or ``nulls_last=False`` to ``Expression.asc()`` and ``Expression.desc()`` methods, and the ``OrderBy`` expression is no - longer allowed. + longer allowed. \ No newline at end of file diff --git a/docs/releases/5.1.txt b/docs/releases/5.1.txt index a9683d3ae2ce..d56bb3182e15 100644 --- a/docs/releases/5.1.txt +++ b/docs/releases/5.1.txt @@ -1,3 +1,30 @@ +============================================ +Django 5.1 release notes - UNDER DEVELOPMENT +============================================ + +*Expected August 2024* + +Welcome to Django 5.1! + +These release notes cover the :ref:`new features `, as well as +some :ref:`backwards incompatible changes ` you'll +want to be aware of when upgrading from Django 5.0 or earlier. We've +:ref:`begun the deprecation process for some features +`. + +See the :doc:`/howto/upgrade-version` guide if you're updating an existing +project. + +Python compatibility +==================== + +Django 5.1 supports Python 3.10, 3.11, and 3.12. We **highly recommend** and +only officially support the latest release of each series. + +.. _whats-new-5.1: + +What's new in Django 5.1 +======================== Database-level values for ``ForeignKey.on_delete`` -------------------------------------------------- @@ -11,3 +38,290 @@ supports four extra values to specify database-level actions: * ``DB_SET_DEFAULT`` - the value of the foreign key column in the referencing table will be set to its ``db_default`` value. +Minor features +-------------- + +:mod:`django.contrib.admin` +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +* ... + +:mod:`django.contrib.admindocs` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +* ... + +:mod:`django.contrib.auth` +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +* The default iteration count for the PBKDF2 password hasher is increased from + 720,000 to 870,000. + +:mod:`django.contrib.contenttypes` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +* ... + +:mod:`django.contrib.gis` +~~~~~~~~~~~~~~~~~~~~~~~~~ + +* ... + +:mod:`django.contrib.messages` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +* ... + +:mod:`django.contrib.postgres` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +* ... + +:mod:`django.contrib.redirects` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +* ... + +:mod:`django.contrib.sessions` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +* ... + +:mod:`django.contrib.sitemaps` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +* ... + +:mod:`django.contrib.sites` +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +* ... + +:mod:`django.contrib.staticfiles` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +* ... + +:mod:`django.contrib.syndication` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +* ... + +Asynchronous views +~~~~~~~~~~~~~~~~~~ + +* ... + +Cache +~~~~~ + +* ... + +CSRF +~~~~ + +* ... + +Decorators +~~~~~~~~~~ + +* ... + +Email +~~~~~ + +* ... + +Error Reporting +~~~~~~~~~~~~~~~ + +* ... + +File Storage +~~~~~~~~~~~~ + +* ... + +File Uploads +~~~~~~~~~~~~ + +* ... + +Forms +~~~~~ + +* ... + +Generic Views +~~~~~~~~~~~~~ + +* ... + +Internationalization +~~~~~~~~~~~~~~~~~~~~ + +* ... + +Logging +~~~~~~~ + +* ... + +Management Commands +~~~~~~~~~~~~~~~~~~~ + +* ... + +Migrations +~~~~~~~~~~ + +* ... + +Models +~~~~~~ + +* :meth:`.QuerySet.explain` now supports the ``generic_plan`` option on + PostgreSQL 16+. + +Requests and Responses +~~~~~~~~~~~~~~~~~~~~~~ + +* ... + +Security +~~~~~~~~ + +* ... + +Serialization +~~~~~~~~~~~~~ + +* ... + +Signals +~~~~~~~ + +* ... + +Templates +~~~~~~~~~ + +* Custom tags may now set extra data on the ``Parser`` object that will later + be made available on the ``Template`` instance. Such data may be used, for + example, by the template loader, or other template clients. + +Tests +~~~~~ + +* :meth:`~django.test.SimpleTestCase.assertContains`, + :meth:`~django.test.SimpleTestCase.assertNotContains`, and + :meth:`~django.test.SimpleTestCase.assertInHTML` assertions now add haystacks + to assertion error messages. + +URLs +~~~~ + +* ... + +Utilities +~~~~~~~~~ + +* ... + +Validators +~~~~~~~~~~ + +* ... + +.. _backwards-incompatible-5.1: + +Backwards incompatible changes in 5.1 +===================================== + +Database backend API +-------------------- + +This section describes changes that may be needed in third-party database +backends. + +* ... + +:mod:`django.contrib.gis` +------------------------- + +* Support for PostGIS 2.5 is removed. + +Dropped support for MariaDB 10.4 +-------------------------------- + +Upstream support for MariaDB 10.4 ends in June 2024. Django 5.1 supports +MariaDB 10.5 and higher. + +Dropped support for PostgreSQL 12 +--------------------------------- + +Upstream support for PostgreSQL 12 ends in November 2024. Django 5.1 supports +PostgreSQL 13 and higher. + +Miscellaneous +------------- + +* In order to improve accessibility, the admin's changelist filter is now + rendered in a ``