diff --git a/AUTHORS b/AUTHORS index 371bfe070ccc..4ab8403fea80 100644 --- a/AUTHORS +++ b/AUTHORS @@ -672,6 +672,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 6e1187fd37e6..8a4644e7102c 100644 --- a/django/db/backends/base/operations.py +++ b/django/db/backends/base/operations.py @@ -166,6 +166,17 @@ def deferrable_sql(self): """ return '' + 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 distinct_sql(self, fields, params): """ Return an SQL DISTINCT clause which removes duplicate rows from the diff --git a/django/db/backends/base/schema.py b/django/db/backends/base/schema.py index 61f88401abff..e6884cc1aed2 100644 --- a/django/db/backends/base/schema.py +++ b/django/db/backends/base/schema.py @@ -78,7 +78,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)s%(deferrable)s" ) sql_create_inline_fk = None sql_create_column_inline_fk = None @@ -176,6 +176,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': self._create_on_delete_sql(model, field), } elif self.connection.features.supports_foreign_keys: self.deferred_sql.append(self._create_fk_sql(model, field, '_fk_%(to_table)s_%(to_column)s')) @@ -1032,6 +1033,12 @@ def _rename_field_sql(self, table, old_field, new_field, new_type): "type": new_type, } + def _create_on_delete_sql(self, model, field): + on_delete = getattr(field.remote_field, 'on_delete', None) + if on_delete and on_delete.with_db: + return on_delete.as_sql(self.connection) + return '' + def _create_fk_sql(self, model, field, suffix): table = Table(model._meta.db_table, self.quote_name) name = self._fk_constraint_name(model, field, suffix) @@ -1047,6 +1054,7 @@ def _create_fk_sql(self, model, field, suffix): to_table=to_table, to_column=to_column, deferrable=deferrable, + on_delete=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 a4885c563241..a52d32b697c2 100644 --- a/django/db/backends/sqlite3/schema.py +++ b/django/db/backends/sqlite3/schema.py @@ -14,7 +14,10 @@ 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" + sql_create_inline_fk = ( + "REFERENCES %(to_table)s (%(to_column)s) " + "%(on_delete)sDEFERRABLE INITIALLY DEFERRED" + ) sql_create_unique = "CREATE UNIQUE INDEX %(name)s ON %(table)s (%(columns)s)" sql_delete_unique = "DROP INDEX %(name)s" diff --git a/django/db/migrations/serializer.py b/django/db/migrations/serializer.py index ead81c398a6d..53553c64b944 100644 --- a/django/db/migrations/serializer.py +++ b/django/db/migrations/serializer.py @@ -217,6 +217,15 @@ def serialize(self): return string.rstrip(','), imports +class OnDeleteSerializer(BaseSerializer): + def serialize(self): + if self.value.value is None: + return 'models.%s' % (self.value.name), {} + if self.value: + return 'models.%s(%s)' % (self.value.name, self.value.value), {} + return None + + class RegexSerializer(BaseSerializer): def serialize(self): regex_pattern, pattern_imports = serializer_factory(self.value.pattern).serialize() @@ -298,6 +307,7 @@ class Serializer: collections.abc.Iterable: IterableSerializer, (COMPILED_REGEX_TYPE, RegexObject): RegexSerializer, uuid.UUID: UUIDSerializer, + models.OnDelete: OnDeleteSerializer, } @classmethod diff --git a/django/db/models/__init__.py b/django/db/models/__init__.py index 7af6e60c5169..183b19025d59 100644 --- a/django/db/models/__init__.py +++ b/django/db/models/__init__.py @@ -5,8 +5,9 @@ from django.db.models.constraints import * # NOQA from django.db.models.constraints import __all__ as constraints_all from django.db.models.deletion import ( - CASCADE, DO_NOTHING, PROTECT, RESTRICT, SET, SET_DEFAULT, SET_NULL, - ProtectedError, RestrictedError, + CASCADE, DB_CASCADE, DB_RESTRICT, DB_SET_NULL, DO_NOTHING, PROTECT, + RESTRICT, SET, SET_DEFAULT, SET_NULL, OnDelete, ProtectedError, + RestrictedError, ) from django.db.models.enums import * # NOQA from django.db.models.enums import __all__ as enums_all @@ -37,8 +38,9 @@ __all__ = aggregates_all + constraints_all + enums_all + fields_all + indexes_all __all__ += [ 'ObjectDoesNotExist', 'signals', - 'CASCADE', 'DO_NOTHING', 'PROTECT', 'RESTRICT', 'SET', 'SET_DEFAULT', - 'SET_NULL', 'ProtectedError', 'RestrictedError', + 'CASCADE', 'DB_CASCADE', 'DB_RESTRICT', 'DB_SET_NULL', 'DO_NOTHING', + 'PROTECT', 'RESTRICT', 'SET', 'SET_DEFAULT', 'SET_NULL', 'OnDelete', + 'ProtectedError', 'RestrictedError', 'Case', 'Exists', 'Expression', 'ExpressionList', 'ExpressionWrapper', 'F', 'Func', 'OrderBy', 'OuterRef', 'RowRange', 'Subquery', 'Value', 'ValueRange', 'When', diff --git a/django/db/models/deletion.py b/django/db/models/deletion.py index 16dff6a1cdb2..17aa14c0c31f 100644 --- a/django/db/models/deletion.py +++ b/django/db/models/deletion.py @@ -20,7 +20,25 @@ def __init__(self, msg, restricted_objects): super().__init__(msg, restricted_objects) -def CASCADE(collector, field, sub_objs, using): +class OnDelete: + def __init__(self, name, operation, value=None): + self.name = name + self.operation = operation + self.value = value + if not self.with_db and not callable(self.operation): + raise TypeError('operation should be callable') + if self.with_db and callable(self.operation): + raise TypeError('operation should be string not callable') + + def __call__(self, *args, **kwargs): + return self.operation(*args, **kwargs) + + @property + def with_db(self): + return hasattr(self, 'as_sql') + + +def application_cascade(collector, field, sub_objs, using): collector.collect( sub_objs, source=field.remote_field.model, source_attr=field.name, nullable=field.null, fail_on_restricted=False, @@ -29,7 +47,7 @@ def CASCADE(collector, field, sub_objs, using): collector.add_field_update(field, None, sub_objs) -def PROTECT(collector, field, sub_objs, using): +def application_protect(collector, field, sub_objs, using): raise ProtectedError( "Cannot delete some instances of model '%s' because they are " "referenced through a protected foreign key: '%s.%s'" % ( @@ -39,12 +57,12 @@ def PROTECT(collector, field, sub_objs, using): ) -def RESTRICT(collector, field, sub_objs, using): +def application_restrict(collector, field, sub_objs, using): collector.add_restricted_objects(field, sub_objs) collector.add_dependency(field.remote_field.model, field.model) -def SET(value): +def application_set(value): if callable(value): def set_on_delete(collector, field, sub_objs, using): collector.add_field_update(field, value(), sub_objs) @@ -52,21 +70,43 @@ def set_on_delete(collector, field, sub_objs, using): def set_on_delete(collector, field, sub_objs, using): collector.add_field_update(field, value, sub_objs) set_on_delete.deconstruct = lambda: ('django.db.models.SET', (value,), {}) - return set_on_delete + return OnDelete('SET', set_on_delete, value) -def SET_NULL(collector, field, sub_objs, using): +def application_set_null(collector, field, sub_objs, using): collector.add_field_update(field, None, sub_objs) -def SET_DEFAULT(collector, field, sub_objs, using): +def application_set_default(collector, field, sub_objs, using): collector.add_field_update(field, field.get_default(), sub_objs) -def DO_NOTHING(collector, field, sub_objs, using): +def application_do_nothing(collector, field, sub_objs, using): pass +CASCADE = OnDelete('CASCADE', application_cascade) +DO_NOTHING = OnDelete('DO_NOTHING', application_do_nothing) +PROTECT = OnDelete('PROTECT', application_protect) +RESTRICT = OnDelete('RESTRICT', application_restrict) +SET = OnDelete('SET', application_set) +SET_NULL = OnDelete('SET_NULL', application_set_null) +SET_DEFAULT = OnDelete('SET_DEFAULT', application_set_default) + + +class DatabaseOnDelete(OnDelete): + def __call__(self, collector, field, sub_objs, using): + raise TypeError('operation should be string not callable') + + def as_sql(self, connection): + return connection.ops.fk_on_delete_sql(self.operation) + + +DB_CASCADE = DatabaseOnDelete('DB_CASCADE', 'CASCADE') +DB_RESTRICT = DatabaseOnDelete('DB_RESTRICT', 'RESTRICT') +DB_SET_NULL = DatabaseOnDelete('DB_SET_NULL', 'SET NULL') + + def get_candidate_relations_to_delete(opts): # The candidate relations are the ones that come from N-1 and 1-1 relations. # N-N (i.e., many-to-many) relations aren't candidates for deletion. @@ -190,6 +230,7 @@ def can_fast_delete(self, objs, from_field=None): all(link == from_field for link in opts.concrete_model._meta.parents.values()) and # Foreign keys pointing to this model. all( + related.field.remote_field.on_delete.with_db or related.field.remote_field.on_delete is DO_NOTHING for related in get_candidate_relations_to_delete(opts) ) and ( @@ -271,7 +312,10 @@ def collect(self, objs, source=None, nullable=False, collect_related=True, if keep_parents and related.model in parents: continue field = related.field - if field.remote_field.on_delete == DO_NOTHING: + if ( + field.remote_field.on_delete.with_db or + field.remote_field.on_delete == DO_NOTHING + ): 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 5ce7fac42058..f84104bcc334 100644 --- a/django/db/models/fields/related.py +++ b/django/db/models/fields/related.py @@ -10,7 +10,10 @@ 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, DB_CASCADE, DB_RESTRICT, DB_SET_NULL, RESTRICT, SET_DEFAULT, + SET_NULL, +) from django.db.models.query_utils import PathInfo from django.db.models.utils import make_model_tuple from django.utils.functional import cached_property @@ -854,6 +857,57 @@ def _check_on_delete(self): id='fields.E321', ) ] + elif on_delete in [DB_CASCADE, DB_RESTRICT, DB_SET_NULL] 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_* on model ' + 'declaring a GenericForeignKey.', + hint='Change the on_delete rule.', + obj=self, + id='fields.E345', + ) + ] + elif on_delete in [DB_CASCADE, DB_RESTRICT, DB_SET_NULL] and len( + # multi table inheritance + self.model._meta.concrete_model._meta.parents.values() + ): + return [ + checks.Error( + 'Field specifies unsupported on_delete=DB_* on multi-table' + ' inherited model.', + hint='Change the on_delete rule.', + obj=self, + id='fields.E345', + ) + ] + elif on_delete in [CASCADE, RESTRICT, SET_DEFAULT, SET_NULL] and ( + any( # field_A <- CASCADE <- field_B <- DB_CASCADE <- field_C + getattr(field_B.remote_field, 'on_delete', None) and + getattr(field_B.remote_field, 'on_delete') in [ + DB_CASCADE, DB_RESTRICT, DB_SET_NULL + ] + for field_B in self.remote_field.model._meta.fields + ) + ): + return [ + checks.Error( + 'Field specifies unsupported on_delete relation with a ' + 'model also using an on_delete=DB_* relation.', + hint='Change the on_delete rule so that DB_* relations ' + 'point to models using DB_* or DO_NOTHING relations.', + obj=self, + id='fields.E345', + ) + ] else: return [] diff --git a/docs/ref/checks.txt b/docs/ref/checks.txt index ec3735598a02..e53658f559c3 100644 --- a/docs/ref/checks.txt +++ b/docs/ref/checks.txt @@ -281,6 +281,8 @@ Related fields with a ``through`` model. * **fields.W344**: The field's intermediary table ```` clashes with the table name of ````/``.``. +* **fields.E345**: Field specifies unsupported ``on_delete=`` model + relation. This would interfere with other expected model behaviors. Models ------ diff --git a/docs/ref/models/fields.txt b/docs/ref/models/fields.txt index 7c5401046b35..8924e9a4d2e8 100644 --- a/docs/ref/models/fields.txt +++ b/docs/ref/models/fields.txt @@ -1550,6 +1550,31 @@ 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 + + .. versionadded:: 3.1 + + Django takes no direct action, behaves exactly like ``DO_NOTHING``, + but also sets ``ON DELETE CASCADE`` as a SQL constraint. The cascade + deletion then happens at the database level. + +* .. attribute:: DB_SET_NULL + + .. versionadded:: 3.1 + + Django takes no direct action, behaves exactly like ``DO_NOTHING``, but + also adds ``ON DELETE SET NULL`` as a SQL constraint. The setting of + a ``NULL`` value then happens at the database level. + +* .. attribute:: DB_RESTRICT + + .. versionadded:: 3.1 + + Django takes no direct action, behaves exactly like ``DO_NOTHING``, but + also adds ``ON DELETE RESTRICT`` as a SQL constraint. The immediate + check of referential integrity (e.g. no longer deferred) + happens at the database level. + .. attribute:: ForeignKey.limit_choices_to Sets a limit to the available choices for this field when this field is diff --git a/docs/releases/3.1.txt b/docs/releases/3.1.txt index 492622d3bd8d..1dd3bc1e1862 100644 --- a/docs/releases/3.1.txt +++ b/docs/releases/3.1.txt @@ -317,6 +317,11 @@ Models :class:`~django.db.models.DateTimeField`, and the new :lookup:`iso_week_day` lookup allows querying by an ISO-8601 day of week. +* The ``on_delete`` argument for ``ForeignKey`` and ``OneToOneField`` now accepts + ``DB_CASCADE``, ``DB_SET_NULL``, and ``DB_RESTRICT``. These + will behave like ``DO_NOTHING`` in Django, but leverage the ``ON DELETE`` SQL + constraint equivalents. + * :meth:`.QuerySet.explain` now supports: * ``TREE`` format on MySQL 8.0.16+, diff --git a/tests/delete/models.py b/tests/delete/models.py index 440581dc5410..d2bde4444451 100644 --- a/tests/delete/models.py +++ b/tests/delete/models.py @@ -164,6 +164,18 @@ class SecondReferrer(models.Model): ) +class BaseDbCascade(models.Model): + pass + + +class RelToBaseDbCascade(models.Model): + name = models.CharField(max_length=30) + + db_cascade = models.ForeignKey(BaseDbCascade, models.DB_CASCADE, null=True, related_name='db_cascade_set') + db_set_null = models.ForeignKey(BaseDbCascade, models.DB_SET_NULL, null=True, related_name='db_set_null_set') + db_restrict = models.ForeignKey(BaseDbCascade, models.DB_RESTRICT, null=True, related_name='db_restrict_set') + + class DeleteTop(models.Model): b1 = GenericRelation('GenericB1') b2 = GenericRelation('GenericB2') diff --git a/tests/delete/tests.py b/tests/delete/tests.py index d8424670c91c..d0664a3638d8 100644 --- a/tests/delete/tests.py +++ b/tests/delete/tests.py @@ -1,16 +1,17 @@ from math import ceil -from django.db import connection, models +from django.db import IntegrityError, connection, models, transaction from django.db.models import ProtectedError, RestrictedError -from django.db.models.deletion import Collector +from django.db.models.deletion import Collector, DatabaseOnDelete, OnDelete from django.db.models.sql.constants import GET_ITERATOR_CHUNK_SIZE from django.test import TestCase, skipIfDBFeature, skipUnlessDBFeature from .models import ( - B1, B2, B3, MR, A, Avatar, B, Base, Child, DeleteBottom, DeleteTop, - GenericB1, GenericB2, GenericDeleteBottom, HiddenUser, HiddenUserProfile, - M, M2MFrom, M2MTo, MRNull, Origin, P, Parent, R, RChild, RChildChild, - Referrer, S, T, User, create_a, get_default_r, + B1, B2, B3, MR, A, Avatar, B, Base, BaseDbCascade, Child, DeleteBottom, + DeleteTop, GenericB1, GenericB2, GenericDeleteBottom, HiddenUser, + HiddenUserProfile, M, M2MFrom, M2MTo, MRNull, Origin, P, Parent, R, RChild, + RChildChild, Referrer, RelToBaseDbCascade, S, T, User, create_a, + get_default_r, ) @@ -263,6 +264,92 @@ def test_restrict_gfk_no_fast_delete(self): self.assertFalse(GenericDeleteBottom.objects.exists()) +@skipUnlessDBFeature('supports_foreign_keys') +class OnDeleteDbTests(TestCase): + def test_on_delete_type_errors(self): + with self.assertRaises(TypeError): + DatabaseOnDelete('DB_NEW_THING', lambda x: x) + + with self.assertRaises(TypeError): + OnDelete('NEW_THING', 'SQL STRING') + + def test_db_cascade(self): + a = BaseDbCascade.objects.create() + b = RelToBaseDbCascade.objects.create(name='db_cascade', db_cascade=a) + b.db_cascade.delete() + self.assertIs( + BaseDbCascade.objects.filter(pk=a.pk).exists(), False + ) + self.assertIs( + RelToBaseDbCascade.objects.filter(name='db_cascade').exists(), + False + ) + + def test_db_cascade_query_count(self): + """A models.DB_CASCADE relation doesn't trigger a query per table.""" + a = BaseDbCascade.objects.create() + b = RelToBaseDbCascade.objects.create(name='db_cascade', db_cascade=a) + with self.assertNumQueries(1): + b.db_cascade.delete() + + def test_db_set_null(self): + a = BaseDbCascade.objects.create() + b = RelToBaseDbCascade.objects.create( + name='db_set_null', + db_set_null=a + ) + b.db_set_null.delete() + self.assertIs( + RelToBaseDbCascade.objects.filter(name='db_set_null').exists(), + True + ) + self.assertEqual( + RelToBaseDbCascade.objects.get(name='db_set_null').db_set_null, + None + ) + + def test_set_null_query_count(self): + """A models.DB_SET_NULL relation doesn't trigger a query per table.""" + a = BaseDbCascade.objects.create() + b = RelToBaseDbCascade.objects.create( + name='db_set_null', + db_set_null=a + ) + with self.assertNumQueries(1): + b.db_set_null.delete() + + def test_db_restrict(self): + a = BaseDbCascade.objects.create() + b = RelToBaseDbCascade.objects.create( + name='db_restrict', + db_restrict=a + ) + with self.assertRaises(IntegrityError): + with transaction.atomic(): + b.db_restrict.delete() + self.assertIs( + RelToBaseDbCascade.objects.filter(name='db_restrict').exists(), + True + ) + self.assertIsNotNone( + RelToBaseDbCascade.objects.get( + name='db_restrict' + ).db_restrict + ) + + def test_db_restrict_query_count(self): + """A models.DB_RESTRICT relation doesn't trigger a query.""" + a = BaseDbCascade.objects.create() + b = RelToBaseDbCascade.objects.create( + name='db_restrict', + db_restrict=a + ) + with self.assertRaises(IntegrityError): + with transaction.atomic(): + with self.assertNumQueries(1): + b.db_restrict.delete() + + class DeletionTests(TestCase): def test_m2m(self): diff --git a/tests/invalid_models_tests/test_relative_fields.py b/tests/invalid_models_tests/test_relative_fields.py index 7e32cced60da..29c93cb22763 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, Warning as DjangoWarning from django.db import connection, models from django.test.testcases import SimpleTestCase @@ -491,6 +493,50 @@ class MMembership(models.Model): ) ]) + def test_on_delete_db_multi_inheritance(self): + class Category(models.Model): + pass + + class PersonMulti(models.Model): + pass + + class PersonExtraMulti(PersonMulti): + category = models.ForeignKey(Category, on_delete=models.DB_CASCADE) + + field = PersonExtraMulti._meta.get_field('category') + self.assertEqual(field.check(), [ + Error( + 'Field specifies unsupported on_delete=DB_* on ' + 'multi-table inherited model.', + hint='Change the on_delete rule.', + obj=field, + id='fields.E345', + ) + ]) + + def test_on_delete_db_cascade_propagate_cascade(self): + class C(models.Model): + pass + + class B(models.Model): + c = models.ForeignKey(C, on_delete=models.DB_CASCADE) + + class A(models.Model): + b = models.ForeignKey(B, on_delete=models.CASCADE) + + field = A._meta.get_field('b') + self.assertEqual(field.check(), [ + Error( + 'Field specifies unsupported on_delete relation with ' + 'a model also using an on_delete=DB_* relation.', + hint='Change the on_delete rule so that DB_* ' + 'relations point to models using DB_* or ' + 'DO_NOTHING relations.', + obj=field, + id='fields.E345', + ) + ]) + def test_foreign_object_to_partially_unique_field(self): class Person(models.Model): country_id = models.IntegerField() @@ -829,6 +875,71 @@ class Model(models.Model): ]) +class ContentTypeFieldTests(SimpleTestCase): + def test_on_delete_db_cascade_generic_fk(self): + class ModelDbCascade(models.Model): + content_type = models.ForeignKey( + ContentType, + on_delete=models.DB_CASCADE, + related_name='model_db_cascade_test' + ) + object_id = models.PositiveIntegerField() + content_object = GenericForeignKey('content_type', 'object_id') + + field = ModelDbCascade._meta.get_field('content_type') + self.assertEqual(field.check(), [ + Error( + 'Field specifies unsupported on_delete=DB_* on model ' + 'declaring a GenericForeignKey.', + hint='Change the on_delete rule.', + obj=field, + id='fields.E345', + ) + ]) + + def test_on_delete_db_restrict_generic_fk(self): + class ModelDbRestrict(models.Model): + content_type = models.ForeignKey( + ContentType, + on_delete=models.DB_RESTRICT, + related_name='model_db_restrict_test' + ) + object_id = models.PositiveIntegerField() + content_object = GenericForeignKey('content_type', 'object_id') + + field = ModelDbRestrict._meta.get_field('content_type') + self.assertEqual(field.check(), [ + Error( + 'Field specifies unsupported on_delete=DB_* on model ' + 'declaring a GenericForeignKey.', + hint='Change the on_delete rule.', + obj=field, + id='fields.E345', + ) + ]) + + def test_on_delete_db_set_null_generic_fk(self): + class ModelDbSetNull(models.Model): + content_type = models.ForeignKey( + ContentType, + on_delete=models.DB_SET_NULL, + related_name='model_db_set_null_test' + ) + object_id = models.PositiveIntegerField() + content_object = GenericForeignKey('content_type', 'object_id') + + field = ModelDbSetNull._meta.get_field('content_type') + self.assertEqual(field.check(), [ + Error( + 'Field specifies unsupported on_delete=DB_* on model ' + 'declaring a GenericForeignKey.', + hint='Change the on_delete rule.', + obj=field, + id='fields.E345', + ) + ]) + + @isolate_apps('invalid_models_tests') class AccessorClashTests(SimpleTestCase): diff --git a/tests/schema/tests.py b/tests/schema/tests.py index 9aa1e239acf2..59ad07333437 100644 --- a/tests/schema/tests.py +++ b/tests/schema/tests.py @@ -2611,6 +2611,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": editor._create_on_delete_sql(model, field), } ) self.assertIn(expected_constraint_name, self.get_constraints(model._meta.db_table))