diff --git a/django/core/exceptions.py b/django/core/exceptions.py index 7a02aa19df..cbc80bd78f 100644 --- a/django/core/exceptions.py +++ b/django/core/exceptions.py @@ -25,6 +25,10 @@ class ObjectDoesNotExist(Exception): silent_variable_failure = True +class ObjectNotUpdated(Exception): + """The updated object no longer exists.""" + + class MultipleObjectsReturned(Exception): """The query returned multiple objects when only one was expected.""" diff --git a/django/db/models/base.py b/django/db/models/base.py index 6da65d5afe..c528d299c4 100644 --- a/django/db/models/base.py +++ b/django/db/models/base.py @@ -17,6 +17,7 @@ from django.core.exceptions import ( FieldError, MultipleObjectsReturned, ObjectDoesNotExist, + ObjectNotUpdated, ValidationError, ) from django.db import ( @@ -171,6 +172,23 @@ class ModelBase(type): attached_to=new_class, ), ) + new_class.add_to_class( + "NotUpdated", + subclass_exception( + "NotUpdated", + tuple( + x.NotUpdated + for x in parents + if hasattr(x, "_meta") and not x._meta.abstract + ) + # Subclass DatabaseError as well for backward compatibility + # reasons as __subclasshook__ is not taken into account on + # exception handling. + or (ObjectNotUpdated, DatabaseError), + module, + attached_to=new_class, + ), + ) if base_meta and not base_meta.abstract: # Non-abstract child classes inherit some attributes from their # non-abstract parent (unless an ABC comes before it in the @@ -1073,9 +1091,11 @@ class Model(AltersData, metaclass=ModelBase): base_qs, using, pk_val, values, update_fields, forced_update ) if force_update and not updated: - raise DatabaseError("Forced update did not affect any rows.") + raise self.NotUpdated("Forced update did not affect any rows.") if update_fields and not updated: - raise DatabaseError("Save with update_fields did not affect any rows.") + raise self.NotUpdated( + "Save with update_fields did not affect any rows." + ) if not updated: if meta.order_with_respect_to: # If this is a model with an order_with_respect_to diff --git a/docs/ref/exceptions.txt b/docs/ref/exceptions.txt index f9f7c3c498..a105679a9b 100644 --- a/docs/ref/exceptions.txt +++ b/docs/ref/exceptions.txt @@ -33,6 +33,20 @@ Django core exception classes are defined in ``django.core.exceptions``. See :meth:`~django.db.models.query.QuerySet.get()`. +``ObjectNotUpdated`` +-------------------- + +.. versionadded:: 6.0 + +.. exception:: ObjectNotUpdated + + The base class for :exc:`Model.NotUpdated + ` exceptions. A ``try/except`` for + ``ObjectNotUpdated`` will catch + :exc:`~django.db.models.Model.NotUpdated` exceptions for all models. + + See :meth:`~django.db.models.Model.save()`. + ``EmptyResultSet`` ------------------ diff --git a/docs/ref/models/class.txt b/docs/ref/models/class.txt index c0ccb6caff..4890955fe7 100644 --- a/docs/ref/models/class.txt +++ b/docs/ref/models/class.txt @@ -39,6 +39,23 @@ Attributes The exception is a subclass of :exc:`django.core.exceptions.MultipleObjectsReturned`. +``NotUpdated`` +-------------- + +.. versionadded:: 6.0 + +.. exception:: Model.NotUpdated + + This exception is raised when :ref:`a forced update + ` of a :class:`~django.db.models.Model` instance + does not affect any rows. + + Django provides a ``NotUpdated`` exception as an attribute of each model + class to identify the class of object that could not be updated, allowing + you to catch exceptions for a particular model class. The exception is a + subclass of :exc:`django.core.exceptions.ObjectNotUpdated` and inherits + from :exc:`django.db.DatabaseError` for backward compatibility reasons. + ``objects`` ----------- diff --git a/docs/ref/models/instances.txt b/docs/ref/models/instances.txt index be4ad4a4f4..e15e1c93bf 100644 --- a/docs/ref/models/instances.txt +++ b/docs/ref/models/instances.txt @@ -601,6 +601,12 @@ You can pass ``force_insert=(models.Model,)`` to force an ``INSERT`` statement for all parents. By default, ``force_insert=True`` only forces the insertion of a new row for the current model. +.. versionchanged:: 6.0 + + When a forced update does not affect any rows a + :exc:`~django.db.models.Model.NotUpdated` exception is raised. On previous + versions a generic :exc:`django.db.DatabaseError` was raised. + It should be very rare that you'll need to use these parameters. Django will almost always do the right thing and trying to override that will lead to errors that are difficult to track down. This feature is for advanced use diff --git a/docs/releases/6.0.txt b/docs/releases/6.0.txt index a5679dd63c..f767e46269 100644 --- a/docs/releases/6.0.txt +++ b/docs/releases/6.0.txt @@ -194,6 +194,11 @@ Models values concatenated into a string, separated by the ``delimiter`` string. This aggregate was previously supported only for PostgreSQL. +* The :meth:`~django.db.models.Model.save` method now raises a specialized + :exc:`Model.NotUpdated ` exception, when + :ref:`a forced update ` results in no affected rows, + instead of a generic :exc:`django.db.DatabaseError`. + Requests and Responses ~~~~~~~~~~~~~~~~~~~~~~ diff --git a/tests/force_insert_update/tests.py b/tests/force_insert_update/tests.py index cc223cf3ea..619b8b413d 100644 --- a/tests/force_insert_update/tests.py +++ b/tests/force_insert_update/tests.py @@ -1,3 +1,4 @@ +from django.core.exceptions import ObjectNotUpdated from django.db import DatabaseError, IntegrityError, models, transaction from django.test import TestCase @@ -50,8 +51,14 @@ class ForceTests(TestCase): # the data isn't in the database already. obj = WithCustomPK(name=1, value=1) msg = "Forced update did not affect any rows." - with self.assertRaisesMessage(DatabaseError, msg): - with transaction.atomic(): + # Make sure backward compatibility with DatabaseError is preserved. + exceptions = [DatabaseError, ObjectNotUpdated, WithCustomPK.NotUpdated] + for exception in exceptions: + with ( + self.subTest(exception), + self.assertRaisesMessage(DatabaseError, msg), + transaction.atomic(), + ): obj.save(force_update=True) diff --git a/tests/update_only_fields/tests.py b/tests/update_only_fields/tests.py index 2c86995799..9595c767eb 100644 --- a/tests/update_only_fields/tests.py +++ b/tests/update_only_fields/tests.py @@ -1,3 +1,5 @@ +from django.core.exceptions import ObjectNotUpdated +from django.db import DatabaseError, transaction from django.db.models.signals import post_save, pre_save from django.test import TestCase @@ -292,3 +294,17 @@ class UpdateOnlyFieldsTests(TestCase): employee_boss = Employee.objects.create(name="Boss", gender="F") with self.assertRaisesMessage(ValueError, self.msg % "id"): employee_boss.save(update_fields=["id"]) + + def test_update_fields_not_updated(self): + obj = Person.objects.create(name="Sara", gender="F") + Person.objects.filter(pk=obj.pk).delete() + msg = "Save with update_fields did not affect any rows." + # Make sure backward compatibility with DatabaseError is preserved. + exceptions = [DatabaseError, ObjectNotUpdated, Person.NotUpdated] + for exception in exceptions: + with ( + self.subTest(exception), + self.assertRaisesMessage(DatabaseError, msg), + transaction.atomic(), + ): + obj.save(update_fields=["name"])