Opened 20 months ago
Closed 4 days ago
#36259 closed Bug (invalid)
Unsaved related object with primary_key=True field does not raise unsaved object error
| Reported by: | Clifford Gama | Owned by: | Alin Morosanu |
|---|---|---|---|
| Component: | Database layer (models, ORM) | Version: | dev |
| Severity: | Normal | Keywords: | |
| Cc: | Csirmaz Bendegúz, Simon Charette | Triage Stage: | Unreviewed |
| Has patch: | no | Needs documentation: | no |
| Needs tests: | no | Patch needs improvement: | no |
| Easy pickings: | no | UI/UX: | no |
Description (last modified by )
Since models defined with custom primary keys can have pk set before being saved to the database, this means they bypass this as if they were saved. Here is an example:
-
tests/one_to_one/tests.py
diff --git a/tests/one_to_one/tests.py b/tests/one_to_one/tests.py index 451e97c274..67b1c32e90 100644
a b class OneToOneTests(TestCase): 170 170 with self.assertRaises(Restaurant.DoesNotExist): 171 171 place.restaurant 172 172 173 def test_unsaved_object_with_supplied_primary_key(self): 174 link1 = Place.objects.create(name="Ushaka", address="Ethekwini") 175 link2 = ManualPrimaryKey(primary_key="YTREWQ", name="moonwalk") 176 msg = ( 177 "save() prohibited to prevent data loss due to unsaved related object " 178 "'link2'." 179 ) 180 with self.assertRaisesMessage(ValueError, msg): 181 MultiModel.objects.create(link1=link1, link2=link2, name="Unsavable") 182 173 183 def test_reverse_relationship_cache_cascade(self): 174 184 """ 175 185 Regression test for #9023: accessing the reverse relationship shouldn't
which results in
FE ====================================================================== ERROR: test_unsaved_object_with_supplied_primary_key (one_to_one.tests.OneToOneTests.test_unsaved_object_with_supplied_primary_key) ---------------------------------------------------------------------- Traceback (most recent call last): File "/home/clifford/code/django/django/db/backends/sqlite3/base.py", line 304, in check_constraints raise IntegrityError( django.db.utils.IntegrityError: The row in table 'one_to_one_multimodel' with primary key '1' has an invalid foreign key: one_to_one_multimodel.link2_id contains a value 'YTREWQ' that does not have a corresponding value in one_to_one_manualprimarykey.primary_key. ====================================================================== FAIL: test_unsaved_object_with_supplied_primary_key (one_to_one.tests.OneToOneTests.test_unsaved_object_with_supplied_primary_key) ---------------------------------------------------------------------- Traceback (most recent call last): File "/home/clifford/code/django/tests/one_to_one/tests.py", line 180, in test_unsaved_object_with_supplied_primary_key with self.assertRaisesMessage(ValueError, msg): File "/usr/lib/python3.12/contextlib.py", line 144, in __exit__ next(self.gen) AssertionError: ValueError not raised ---------------------------------------------------------------------- Ran 1 test in 0.004s FAILED (failures=1, errors=1)
And here is the culprit code:
This is done in a few other places. My proposal is to replace such uses cases of _is_pk_set with instance._state.adding checks.
Change History (19)
comment:1 by , 20 months ago
| Description: | modified (diff) |
|---|
comment:2 by , 20 months ago
| Has patch: | set |
|---|
comment:4 by , 20 months ago
Take a look at the last comment: notice the ticket owner already submitted a pull request.
comment:5 by , 20 months ago
| Cc: | added |
|---|---|
| Keywords: | _is_pk_set removed |
| Severity: | Release blocker → Normal |
| Summary: | `_is_pk_set()` is incorrectly used to check for unsaved models. → Unsaved related object with primary_key=True field does not raise usaved object error |
| Triage Stage: | Unreviewed → Accepted |
Hi Clifford, thank you for the report!
This is also failing on 5.1, so this is not a regression introduced by 5.2.
comment:6 by , 20 months ago
Thank you, Sarah!
I should have mentioned in the report that this incorrect use of _is_pk_set() affects also three other cases:
- Access of a generated field on an unsaved instance with a specified primary key.
-
tests/model_fields/test_generatedfield.py
diff --git a/tests/model_fields/test_generatedfield.py b/tests/model_fields/test_generatedfield.py index e00a665ec8..ab80536e38 100644
a b class GeneratedFieldTestMixin: 180 180 181 181 def test_unsaved_error(self): 182 182 m = self.base_model(a=1, b=2) 183 manual_pk_obj = GeneratedModelNonAutoPk(id=101, a="a") 183 184 msg = "Cannot read a generated field from an unsaved model." 184 185 with self.assertRaisesMessage(AttributeError, msg): 185 186 m.field 187 with self.assertRaisesMessage(AttributeError, msg): # Raises integrity error instead 188 manual_pk_obj.b 186 189 187 190 def test_full_clean(self): 188 191 m = self.base_model(a=1, b=2)
-
QuerySet.contains(unsaved_with_manual_pk)raises integrity error.-
tests/queries/test_contains.py
diff --git a/tests/queries/test_contains.py b/tests/queries/test_contains.py index 2aa4badc72..aef3b475f9 100644
a b 1 1 from django.test import TestCase 2 2 3 from .models import D umbCategory, NamedCategory, ProxyCategory3 from .models import DateTimePK, DumbCategory, NamedCategory, ProxyCategory 4 4 5 5 6 6 class ContainsTests(TestCase): … … class ContainsTests(TestCase): 13 13 msg = "QuerySet.contains() cannot be used on unsaved objects." 14 14 with self.assertRaisesMessage(ValueError, msg): 15 15 DumbCategory.objects.contains(DumbCategory()) 16 with self.assertRaisesMessage(ValueError, msg): 17 DateTimePK.objects.contains(DateTimePK()) 16 18 17 19 def test_obj_type(self): 18 20 msg = "'obj' must be a model instance."
-
filter|exclude(obj=unsaved_obj_with_manual_pk)-
tests/queries/tests.py
diff --git a/tests/queries/tests.py b/tests/queries/tests.py index c429a93af3..f30dbb66ff 100644
a b class Queries5Tests(TestCase): 1982 1982 Employment.objects.filter(employer__in=[company, Company(name="unsaved")]) 1983 1983 with self.assertRaisesMessage(ValueError, msg): 1984 1984 StaffUser.objects.filter(staff=Staff(name="unsaved")) 1985 with self.assertRaisesMessage(ValueError, msg): 1986 ExtraInfo.objects.filter(date=DateTimePK()) 1985 1987 1986 1988 1987 1989 class SelectRelatedTests(TestCase): … … class ExcludeTests(TestCase): 3360 3362 Employment.objects.exclude(employer__in=[company, Company(name="unsaved")]) 3361 3363 with self.assertRaisesMessage(ValueError, msg): 3362 3364 StaffUser.objects.exclude(staff=Staff(name="unsaved")) 3365 with self.assertRaisesMessage(ValueError, msg): 3366 ExtraInfo.objects.exclude(date=DateTimePK()) 3363 3367 3364 3368 3365 3369 class ExcludeTest17600(TestCase):
-
comment:7 by , 20 months ago
Thank you for the extra details. I've checked and all of these are still failing on 5.1 so are not regressions introduced by 5.2
I also think each case is roughly covered by the title, with the GeneratedField being a special case
comment:8 by , 20 months ago
| Summary: | Unsaved related object with primary_key=True field does not raise usaved object error → Unsaved related object with primary_key=True field does not raise unsaved object error |
|---|
comment:9 by , 17 months ago
| Patch needs improvement: | set |
|---|
comment:10 by , 3 months ago
| Keywords: | Django Sprint added |
|---|---|
| Owner: | changed from to |
Assigning to Django Sprints for to reserve for an upcoming sprint. DjangoCon US ends August 28, this is available for someone else to pick up on the 29th.
Clifford Gama, if you'd like to continue to work on this please re-assign this to yourself.
comment:11 by , 5 weeks ago
| Owner: | changed from to |
|---|
comment:12 by , 3 weeks ago
| Owner: | changed from to |
|---|
comment:13 by , 3 weeks ago
The ticket is assigned to me. My next step is to read the existing review and confirm the first case to investigate
follow-up: 15 comment:14 by , 3 weeks ago
I’m investigating #36259, which reports that "Django’s unsaved-object checks can be bypassed when an unsaved instance already has a primary key".
The original proposed solution was to use _state.adding to detect unsaved instances. However, in PR #19275, Jacob Walls identified existing workflows that stricter checks would reject:
- Querying related objects through a new UUID-backed instance, such as
pizza.toppings.all(). - Saving interdependent objects within a transaction where foreign-key constraints are checked after both objects have been saved.
I’ve investigated the four cases described in this ticket. On the tested checkout, with SQLite:
- Saving with a missing referenced row raises
IntegrityErrorwhen the constraint is checked. contains()returnsFalsefor an assigned PK with no matching row.- Related
filter()andexclude()queries execute without an unsaved-object error. - Reading an unloaded generated field with an assigned PK but no matching row raises
DoesNotExist.
I have an uncommitted prototype addressing only the generated-field case. It allows the normal lookup, preserving successful retrieval when the PK identifies an existing row. If that lookup raises the model’s DoesNotExist and the instance is marked as adding, it raises the existing unsaved-field AttributeError instead. It does not change saves, membership checks, or related filters.
Seven regression cases fail against the unchanged runtime and pass with this change.
Could a maintainer clarify whether the existing save and query behaviors should be preserved? Would the generated-field exception change be suitable for a separate, narrowly scoped PR, or should a broader behaviour change and possible deprecation be discussed first?
comment:15 by , 3 weeks ago
Replying to Alin Morosanu:
Could a maintainer clarify whether the existing save and query behaviors should be preserved? Would the generated-field exception change be suitable for a separate, narrowly scoped PR, or should a broader behaviour change and possible deprecation be discussed first?
Do you have any new information about it?
comment:16 by , 3 weeks ago
I found no new reason to change the save/query behavior discussed in PR #19275.
I tested a smaller change: reading a generated field on a new instance raises AttributeError when its PK has no matching row, instead of DoesNotExist. Successful lookups and saved-instance errors stay unchanged.
Seven tests fail before the change and pass after it. I found no data loss here; the benefit is a more consistent error. Is that enough reason to change it?
comment:17 by , 2 weeks ago
I updated PR #22182 to cover all four cases in the description. It is still a draft and a proposal, so I'd like a decision on the approach before I polish it.
What the PR does
- Case 2 (generated fields): reading an unloaded generated field on a new instance raises
AttributeErrorinstead ofDoesNotExistwhen no row matches. Lookups that find a row don't change. - Cases 1, 3 and 4: a new instance with a primary key set (
_state.addingisTrue) now warns withRemovedInDjango2029Warningwhen used in a related filter, inQuerySet.contains(), or saved as a cached related object. Results stay the same while it only warns. The docs propose raisingValueErrorin Django 2029. - Reverse related managers (reads), prefetching, inline formsets and deletion use the target values, so they keep working without warnings. This follows Jacob's review on PR #19275: querying related objects should stay allowed, and any change needs a deprecation path.
Questions
- Is
_state.addingthe right check? It also warns for valid code, such as apre_savehandler or a customclean()that filters by a new instance with a default UUID primary key, a filter with a deserialized instance, and saving the related row later in the same transaction. - Should case 2 and the warnings be decided together, or split? Case 2 still loads values from an existing row, while the warnings treat the same kind of instance as not usable in queries.
- Is the 2029 date OK, or should this only warn for now?
Known limit: for acontains() and asave(), the warning points to asgiref, not the caller.
comment:18 by , 9 days ago
I went through the four cases again, along with the review on PR 19275. All examples use an instance built in Python with its primary key set, which was never saved or loaded, e.g. x = Author(pk=1).
Case 1: saving a relation to it
Book.objects.create(author=x)
There's no early ValueError; the database raises IntegrityError if the row doesn't exist. This looks intentional. The comment in Model._prepare_related_fields_for_save() says:
# A pk may have been assigned manually to a model instance not # saved to the database (or auto-generated in a case like # UUIDField), but we allow the save to proceed and rely on the # database to raise an IntegrityError if applicable.
Jacob's review of PR 19275 also showed valid code that depends on this, e.g. creating two related rows inside one transaction.
Case 3: QuerySet.contains()
Author.objects.contains(x)
Returns False if row 1 doesn't exist and True if it does. That's the correct answer to "is this row in the queryset?". The ValueError is there for instances with no primary key, where there's nothing to compare.
Case 4: filter() / exclude()
Book.objects.filter(author=x)
Generates WHERE author_id = 1, the same as filter(author=1). The unsaved-object check came from #31486, where an instance with pk=None was silently treated like author=None. On that ticket, checking obj.pk is None was preferred over obj._state.adding as "less controversial". With a primary key set, the query does what was asked. Jacob's review of PR 19275 also showed that rejecting these breaks valid code such as pizza.toppings.all() inside save().
Case 2: reading an unloaded generated field
This one is inconsistent. For a new instance, the error depends only on whether the primary key happens to be set:
GeneratedModelNonAutoPk(a=5).b # AttributeError: Cannot retrieve deferred field 'b' from an unsaved model. GeneratedModelNonAutoPk(id=99, a=5).b # no row with id=99 # DoesNotExist: GeneratedModelNonAutoPk matching query does not exist.
Both instances are new and unsaved, but the second one runs a query and fails with DoesNotExist.
Suggestion
Cases 1, 3, and 4 look like intended behavior, and changing them would break valid code, so I'd leave them as they are. Case 2 is the only real inconsistency, and I'd like to scope this ticket to it. Simon, Jacob, does that sound reasonable?
As suggested by Alin here https://code.djangoproject.com/ticket/36259?cnum_edit=18#comment:16
comment:19 by , 4 days ago
| Has patch: | unset |
|---|---|
| Keywords: | Django Sprint removed |
| Patch needs improvement: | unset |
| Resolution: | → invalid |
| Status: | assigned → closed |
| Triage Stage: | Accepted → Unreviewed |
Thanks Alin and Farhan for the research here -- it's valuable to record all these findings.
I agree on Cases 1, 3, and 4 being intended behavior based on the current information.
Looking more closely at the GeneratedField case, I think it's also intended behavior. If there's a PK set, and you request the generated field, a query should happen, and you should get DoesNotExist if the row is missing. If there's no PK, no query can be done, so you get an error that says so.
Stepping back for a minute, I think Clifford may have been led down the path of investigating the use of _state.adding due to Django's existing wording in error messages about "unsaved objects". But in reality, the checks are about whether the PK is set. That's understandable. For many Django users without custom primary_key=True fields, and no use case for in-memory "unfetched" instances, the two are equivalent.
I think the most we should be doing here is considering a tweak to the wording of the existing error to mention "unset PK" instead of "unsaved object". If you'd like to propose that separately, we can consider it, but I would suggest a new ticket for it just to keep the discussion a little easier to follow.
PR