Opened 6 months ago

Closed 4 months ago

#36207 closed Bug (fixed)

refresh_from_db() doesn't clear cached ForeignObject relations

Reported by: Jacob Walls Owned by: Jacob Walls
Component: Database layer (models, ORM) Version: 5.2
Severity: Normal Keywords:
Cc: Csirmaz Bendegúz, Lily Foote 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 Jacob Walls)

Although it's an internal API ForeignObject is now quasi-public. When trying it out in a non-composite-pk situation I found that refresh_from_db() didn't clear out a related ForeignObject.

Here is a rough test using the composite_pk models (EDIT: prior version of test wasn't demonstrating anything):

  • tests/composite_pk/test_models.py

    diff --git a/tests/composite_pk/test_models.py b/tests/composite_pk/test_models.py
    index 27157a52ad..7cd97a31a9 100644
    a b class CompositePKModelsTests(TestCase):  
    155155        self.assertEqual(4, token.permission_set.count())
    156156        self.assertEqual(4, user.permission_set.count())
    157157        self.assertEqual(4, comment.permission_set.count())
     158
     159    def test_refresh_foreign_object(self):
     160        self.comment_1.user = None
     161        self.comment_1.refresh_from_db()
     162        self.assertEqual(self.comment_1.user, self.user_1)

E
======================================================================
ERROR: test_refresh_foreign_object (composite_pk.test_models.CompositePKModelsTests.test_refresh_foreign_object)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/.../django/tests/composite_pk/test_models.py", line 161, in test_refresh_foreign_object
    self.comment_1.refresh_from_db()
    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^
  File "/Users/.../py313/lib/python3.13/site-packages/django/db/models/base.py", line 737, in refresh_from_db
    db_instance = db_instance_qs.get()
  File "/Users/.../py313/lib/python3.13/site-packages/django/db/models/query.py", line 633, in get
    raise self.model.DoesNotExist(
        "%s matching query does not exist." % self.model._meta.object_name
    )
composite_pk.models.tenant.Comment.DoesNotExist: Comment matching query does not exist.

----------------------------------------------------------------------
Ran 1 test in 0.008s

FAILED (errors=1)

Change History (21)

comment:1 by Jacob Walls, 6 months ago

Description: modified (diff)

The variant where I found this didn't cause refresh_from_db() to throw, it just silently kept the old value. (This was with a nullable ForeignObject.)

comment:2 by Clifford Gama, 6 months ago

Triage Stage: UnreviewedAccepted

comment:3 by Gregory Mariani, 6 months ago

I'll have a look on it. In the interval if someone want to take the ticket go ahead

comment:4 by Gregory Mariani, 6 months ago

@Jacob
Does this test is also correct with the logic you expect ?

def test_refresh_foreign_object(self):
    # Retrieve the comment via the DB to ensure from_db() is called and _original_pk is set
    comment = Comment.objects.get(pk=self.comment_1.pk)
    # Remove the underlying 'user_id' from __dict__ to simulate a cleared ForeignObject cache
    comment.__dict__.pop("user_id", None)
    # Call refresh_from_db() to reload the original values from the database
    comment.refresh_from_db()
    # Assert that refresh_from_db() has restored 'user' to its original value
    self.assertEqual(comment.user, self.user_1)

comment:5 by Gregory Mariani, 6 months ago

Owner: set to Gregory Mariani
Status: newassigned

comment:6 by Gregory Mariani, 6 months ago

Has patch: set

comment:7 by Jacob Walls, 6 months ago

Thanks for picking this up. I think being able to set .user = None is important. Maybe we need to adjust this query from .get() to .filter(...).first() to support that? The test model I was reusing was not nullable, but you can imagine another test model having a nullable ForeignObject.

Last edited 6 months ago by Jacob Walls (previous) (diff)

comment:8 by Gregory Mariani, 6 months ago

What's the behavior expected ?
Because comment.user = None will set the value None and obviously self.assertEqual(comment.user, self.user_1) will raise with my update:

composite_pk.models.tenant.Comment.user.RelatedObjectDoesNotExist: Comment has no user.

Here the model Comment l30
https://github.com/django/django/blob/51cab4ad51616f8fdb050631be5c710b93685ec3/tests/composite_pk/models/tenant.py
the CASCADE relations are:

from_fields=("tenant_id", "user_id"),
to_fields=("tenant_id", "id")

So if we set user_id to None, the relations are destroyed for a ForeignObject.
Maybe I've misunderstood something

Last edited 6 months ago by Gregory Mariani (previous) (diff)

comment:9 by Jacob Walls, 6 months ago

Because comment.user = None will set the value None

But the test case I sketched doesn't save that change to the database, so my expectation was that refresh_from_db() would return the same thing that a fresh query would return. Or at least this was my expectation based on how ForeignKey works.

So if we set user_id to None, the relations are destroyed for a ForeignObject.

If this is how ForeignObject is supposed to work, then I feel we need to document it, because from the composite PK guide that I linked it's not obvious. However my impression is that it's not supposed to work like .add(), which immediately alters data.

comment:10 by Jacob Walls, 6 months ago

Patch needs improvement: set

comment:11 by Sarah Boyce, 6 months ago

Cc: Csirmaz Bendegúz Lily Foote added
Severity: Release blockerNormal

This also fails on Django 5.1

  • tests/foreign_object/tests.py

    a b class MultiColumnFKTests(TestCase):  
    437437        normal_groups_lists = [list(p.groups.all()) for p in Person.objects.all()]
    438438        self.assertEqual(groups_lists, normal_groups_lists)
    439439
     440    def test_refresh_foreign_object(self):
     441        member = Membership.objects.create(
     442            membership_country=self.usa, person=self.bob, group_id=None
     443        )
     444        self.assertEqual(member.person, self.bob)
     445        member.person = None
     446        member.refresh_from_db()
     447        self.assertEqual(member.person, self.bob)
     448
    440449    @translation.override("fi")
    441450    def test_translations(self):
    442451        a1 = Article.objects.create(pub_date=datetime.date.today())

We documented that this is an internal API, and therefore not supported by our deprecation policy.
I don't think this is supported by our "new feature in 5.2" policy of back porting bug fixes. I am therefore demoting this from a release blocker to "normal".

If this note implies this should work "perfectly", we should remove the suggestion to use ForeignObject.

#35956 aims to add ForeignKey support.

comment:12 by Gregory Mariani, 6 months ago

Patch needs improvement: unset

comment:13 by Jacob Walls, 6 months ago

Patch needs improvement: set

I think I confounded things by involving None in my draft test. I didn't notice that both sides of the relation involved CompositePrimaryKey. Django doesn't support setting a model's PK to None and then refreshing from db--and I'm not suggesting to add support for that.

I left a review on the PR improve the assertion and suggest an alternative approach. It works for me, but some further investigation/validation would be very welcome.

comment:14 by Jacob Walls, 6 months ago

Summary: refresh_from_db() doesn't refresh ForeignObject relationsrefresh_from_db() doesn't clear cached ForeignObject relations

comment:15 by Jacob Walls, 6 months ago

Patch needs improvement: unset

I opened a PR with my proposed alternative.

comment:16 by Jacob Walls, 4 months ago

Owner: changed from Gregory Mariani to Jacob Walls

Thanks Gregory for advancing the investigation here. Since the first PR is now closed, I'm going to set myself in the owner field. Eager to hear any feedback you might have for me.

comment:17 by moshe nahmias, 4 months ago

The test passes and actually test the behavior (so it fails on master but pass on the branch PR).
The code is similar to the surrounding code and looks good

comment:18 by moshe nahmias, 4 months ago

Triage Stage: AcceptedReady for checkin

comment:19 by Clifford Gama, 4 months ago

Triage Stage: Ready for checkinAccepted

comment:20 by Clifford Gama, 4 months ago

Triage Stage: AcceptedReady for checkin

Patch LGTM

The only remaining consideration is whether adding an entry to FORWARD_PROPERTIES without adding a corresponding test—despite limited test coverage for this set—is acceptable. Since:

  1. The risk appears low (no tests fail without it, but stale cache could theoretically occur).
  2. Testing this edge case robustly would likely require invasive model mutations post-setup.
  3. Field-related entries in FORWARD_PROPERTIES are untested (You can remove all but one of them without seeing any test failures.

Leaving this to the merger’s discretion.

comment:21 by Sarah Boyce <42296566+sarahboyce@…>, 4 months ago

Resolution: fixed
Status: assignedclosed

In 69ab6e5:

Fixed #36207 -- Cleared cached ForeignObject relations via refresh_from_db().

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