Opened 3 years ago

Closed 5 weeks ago

#33579 closed New feature (fixed)

Raise a specialized exception when Model.save(update_fields) does not affect any rows

Reported by: Simon Charette Owned by: Simon Charette
Component: Database layer (models, ORM) Version: 4.0
Severity: Normal Keywords:
Cc: Sardorbek Imomaliev, Mariusz Felisiak Triage Stage: Ready for checkin
Has patch: yes Needs documentation: no
Needs tests: no Patch needs improvement: no
Easy pickings: no UI/UX: no

Description (last modified by Simon Charette)

When Model.save(update_fields) is used to update an instance of a previously fetched model and the resulting UPDATE doesn't affect any row a DatabaseError is raised.

Since the resulting exception cannot be differentiated by its type from any other dababase errors occuring during save (e.g. a failed UPDATE also results in a DatabaseError) the only pattern to gracefully handle this rare edge case to catch all DatabaseError and compare its .args[0] (or str representation) to a string that doesn't offer any stability guarantees.

In order to make it easier for advanced users that rely on the update_fields feature to handle this case differently from others where a DatabaseError is raised I propose that that we introduce a new DatabaseError subclass that would be raised instead when an unexpected empty update occurs.

I believe both instances should be switched to this pattern (force_update and update_fields).

Change History (18)

comment:1 by Simon Charette, 3 years ago

Description: modified (diff)

comment:2 by Carlton Gibson, 3 years ago

Triage Stage: UnreviewedAccepted

comment:3 by Mohamed Nabil Rady, 3 years ago

Is it the best the idea to introduce a new subclass rather than using one of the other ones ? Because I think we should stick with the ones specified in PEP 249.
Not sure if we should raise a ProgrammingError or InterfaceError

comment:4 by Simon Charette, 3 years ago

I feel like a django.db.models.Model.save forced update failure has little to do with PEP-249; it's an exception that occurs at the ORM layer and not at the backend one.

Not sure if we should raise a ProgrammingError or InterfaceError

I would argue that we should not subclass either of them as it will result in the same effect as raising DatabaseError from a callers perspective (no way to differentiate between a database level error and an ORM level one without a str(exc) comparison).

The reason for proposing to subclass DatabaseError is solely for backward compatibility purposes which I believed would make the proposed change less controversial.

If it was do all over again I think we should have a dedicated NoopUpdate (better name welcome) type like we do with DoesNotExist and MultipleObjectsReturned but since Python doesn't allow for virtual subclass overrides in except clauses there's no way to provide a deprecation path for except DatabaseError clauses in the wild that might already be handling this case.

comment:5 by Sardorbek Imomaliev, 3 years ago

Cc: Sardorbek Imomaliev added

comment:6 by Dulalet, 3 years ago

Owner: changed from nobody to Dulalet
Status: newassigned

comment:7 by Simon Charette, 3 years ago

Another alternative we would be to simply raise a cls.DoesNotExists error instead.

It would be backward incompatible in the sense that a different exception type would be raised but it seems it would fit elegantly with the message it's meant to convey; the object that was attempted to be updated was not found. It would also make code that handles generic DoesNotExist and turn them into HTTP 404 automatically pick up errors of this form without any special casing.

Another argument for this approach is that the affected base is already available from Model._save_table via the cls kwarg so it would also allow to differentiate which model row was missing when dealing with concrete model inheritance that require update against multiple tables.

Last edited 3 years ago by Simon Charette (previous) (diff)

comment:8 by Enrique Soria, 10 months ago

Has patch: set

comment:9 by Enrique Soria, 10 months ago

I've made a PR for this which simply creates a new exception by inheriting from DatabaseError. This will be compatible with people who already was catching DatabaseError, and will make new developments easier by having a separate exception class.

https://github.com/django/django/pull/18244

comment:10 by Simon Charette, 10 months ago

Patch needs improvement: set

Left some comments on the PR about possible improvement to make the raised exception more coherent with ObjectDoesNotExist and MultipleObjectsReturned while still inheriting from DatabaseError for backward compatiblity reasons.

comment:11 by Enrique Soria, 8 months ago

Patch needs improvement: unset

I've done all request changes, and now I think now it's ready to be merged

comment:12 by Simon Charette, 8 months ago

Needs documentation: set
Needs tests: set
Patch needs improvement: set

Left some comments on the PR. It still needs tests tweaking, some code adjustments, and documentation about django.core.exceptions.ObjectNotUpdated and NotUpdated.

in reply to:  12 comment:13 by Mariusz Felisiak, 7 weeks ago

Cc: Mariusz Felisiak added

Replying to Simon Charette:

Left some comments on the PR. It still needs tests tweaking, some code adjustments, and documentation about django.core.exceptions.ObjectNotUpdated and NotUpdated.

TBH, this error is extremely annoying to me. It forces users to catch DatabaseError on every save() call when you process things asynchronously and don't really care if someone was deleted in the meantime. I'd vote to remove it totally 😉, we don't raise any exception on save() without update_fields.

Last edited 7 weeks ago by Mariusz Felisiak (previous) (diff)

comment:14 by Simon Charette, 5 weeks ago

I'd vote to remove it totally 😉, we don't raise any exception on save() without update_fields.

As annoying as this exception can be I don't think we should entirely silence it. Passing update_fields is synonymous with force_update and just like when force_insert is provided erroring out to let callers know that the forced operation failed seems like the right thing to do as doing otherwise could result in unexpected data loses. Any save() call can raise an IntegrityError (and other flavors of DatabaseError) so even if we removed that particular instance it would still be best practice to handle them on save calls.

At least with a specialized exception, something less generic than DatabaseError, callers can more adequately react to such errors. For example, raised ObjectNotUpdated can be turned into a 404, silenced, or turn into a subsequent save call without update fields depending on the situation.

If you look at the core usages of save(update_fields) there seems to be cases where we'd want to do one over the other. For example, reverse_many_to_one_manager._clear might want to silence such exceptions when assigning None while in RenameContentType._rename you'd most like would want to refetch the row by natural key and update it again.

Once the exception lands and is documented it could allow third party tools like DRF to catch them on PUT / PATCH and turn them into 404s.

Given the patch has not been improved for over 6 months I'll assign it to me and try to bring it through the finish line.

Last edited 5 weeks ago by Simon Charette (previous) (diff)

comment:15 by Simon Charette, 5 weeks ago

Owner: changed from Dulalet to Simon Charette

comment:16 by Simon Charette, 5 weeks ago

Needs documentation: unset
Needs tests: unset
Patch needs improvement: unset

comment:17 by Mariusz Felisiak, 5 weeks ago

Triage Stage: AcceptedReady for checkin

comment:18 by Mariusz Felisiak <felisiak.mariusz@…>, 5 weeks ago

Resolution: fixed
Status: assignedclosed

In ab148c0:

Fixed #33579 -- Specialized exception raised on forced update failures.

Raising DatabaseError directly made it harder than it should to
differentiate between IntegrityError when a forced update resulted in no
affected rows.

Introducing a specialized exception allows for callers to more easily
silence, log, or turn them update failures into user facing exceptions
(e.g. 404s).

Thanks Mariusz for the review.

Note: See TracTickets for help on using tickets.
Back to Top