10000 Fixed #36207 -- Cleared cached ForeignObject relations via refresh_from_db(). by jacobtylerwalls · Pull Request #19245 · django/django · GitHub
[go: up one dir, main page]

Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
May 9, 2025

Conversation

jacobtylerwalls
Copy link
Member

Trac ticket number

ticket-36207

Branch description

Before, model fields using the private API models.ForeignObject to relate foreign objects without enforcing a foreign key (as modeled in the composite pk docs) didn't have their relations cleared via refresh_from_db().

Alternative to #19207

Checklist

  • This PR targets the main branch.
  • The commit message is written in past tense, mentions the ticket number, and ends with a period.
  • I have checked the "Has patch" ticket flag in the Trac system.
  • I have added or updated relevant tests.
  • [n/a] I have added or updated relevant docs, including release notes if applicable.
  • [n/a] I have attached screenshots in both light and dark modes for any UI changes.

Copy link
Contributor
@moshe742 moshe742 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 on lines 633 to 641
@cached_property
def virtual_relations(self):
"""
Return many-to-one related objects that may be virtual, i.e. using
ForeignObjectRel without ManyToOneRel (ForeignKey).

Private API intended only to be used by Django itself.
"""
Copy link
Contributor
@cliff688 cliff688 Apr 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this need to be added to FORWARD_PROPERTIES so the cache can be expired? I think it does because it invokes _get_fields().

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to add context to my earlier note — I did a deeper dive into how FORWARD_PROPERTIES affects cache invalidation in practice.

Removing most field-related entries from the set didn’t cause any test failures. This means missing test coverage, no?

The only field-related entry whose removal did cause test failures was _forward_fields_map, which broke two tests: schema.tests.SchemaTests.test_m2m_repoint_* with RuntimeWarning: Model 'schema.localbookwithm2m_tags' was already registered. Reloading models is not advised as it can lead to inconsistencies, most notably with related models.. I haven’t pinned down the exact reasons, but it likely stems from _get_field(), which builds the forward field map being invoked during the final stages of model preparation, via Options._prepare().

Cache expiration (for field-related properties) is done by Options.add_field(), but this is only called during model class creation, via Field.contribute_to_class(), which is probably why this is mostly untested/ difficult to test.

By contrast, removing manager-related entries did break tests, confirming that those properties are actively used at runtime and need proper invalidation.

While omitting virtual_relations doesn’t break anything now, it does call _get_fields() and could theoretically return stale results if the model were mutated. That said, it’s hard to write a test for this without doing post-setup mutation (). Still, we might want to explore better test coverage for this area overall.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, great catch. We also appear to be missing db_returning_fields, possibly others. Would you be willing to open a ticket with your findings about the test coverage gap & need for a testing strategy?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added "virtual_relations" to FORWARD_PROPERTIES and squashed it in.

Copy link
Contributor
@cliff688 cliff688 May 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @jacobtylerwalls for the updates!

Would you be willing to open a ticket with your findings about the test coverage gap & need for a testing strategy?

I'm happy to open a ticket to highlight these findings, though I don't yet have a concrete testing strategy.

Given the uncertainty around testing FORWARD_PROPERTIES, I think this is ready for a merger's review - marking as RFC. It's up to their discretion whether adding an entry to this (largely untested) set is acceptable, despite the testing challenges.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opened ticket-36369

@jacobtylerwalls jacobtylerwalls force-pushed the fix-refresh-foreign-object branch from 32f5bbc to 324cf32 Compare May 3, 2025 12:47
@@ -753,7 +753,7 @@ def refresh_from_db(self, using=None, fields=None, from_queryset=None):

db_instance = db_instance_qs.get()
non_loaded_fields = db_instance.get_deferred_fields()
for field in self._meta.concrete_fields:
for field in chain(self._meta.concrete_fields, self._meta.virtual_relations):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
for field in chain(self._meta.concrete_fields, self._meta.virtual_relations):
for field in self._meta.fields:

I was wondering why we can't do the above. Virtual fields to me are non-concrete fields, so this would include some virtual fields that are not relations but not sure if that's an issue?

10000
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I should have worked out that those two were complements of each other. Nice!

And extra nice to avoid adding this new property, since I now see it was removed once already in 933dc62 😅

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

However, I noticed while playing with this model -- if there are multiple foreign objects pointing to the same column (e.g. through inheritance), multiple identical queries will be issued on refresh. I'm going to see if that's what the "cached relations" bit just below this is supposed to be handling.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a little test to demonstrate. Guarding the setattr seems to prevent new behavior changes.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How should I see the multiple queries issue? I can see that when I revert the guard, then I get an error running the new test:

    raise FieldDoesNotExist(
django.core.exceptions.FieldDoesNotExist: BasePerson has no field named 'abstract_non_concrete_id'

But not sure where the multiple queries come to play (this is SQLite).

I'm hoping instead of adding a test to where we are testing model meta, perhaps we can modify the Membership model instead:

--- a/tests/foreign_object/models/person.py
+++ b/tests/foreign_object/models/person.py
@@ -68,6 +68,13 @@ class Membership(models.Model):
         to_fields=["id", "group_country"],
         on_delete=models.CASCADE,
     )
+    not_concrete_country = models.ForeignObject(
+        Country,
+        on_delete=models.CASCADE,
+        from_fields=["non_concrete_id"],  # Field does not exist.
+        to_fields=["id"],
+        related_name="non_concrete_country",
+    )
 
     class Meta:
         ordering = ("date_joined", "invite_reason")

This is enough of a change for the same error, but still have an open question on the queries to know if this is "enough".

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How should I see the multiple queries issue?

Ah, I forgot to push up the edits I made to those models. I had to add the missing fields, e.g. the one you marked here as "does not exist". I think this wouldn't function otherwise? I suppose they're missing because that file just has SimpleTestCases and isn't exercising queries?

I can just move the self.assertNumQueries to my original test and have coverage without touching any of these models.

@jacobtylerwalls jacobtylerwalls force-pushed the fix-refresh-foreign-object branch 2 times, most recently from ffd305f to cec9568 Compare May 9, 2025 01:01
@jacobtylerwalls jacobtylerwalls force-pushed the fix-refresh-foreign-object branch from cec9568 to dc2b083 Compare May 9, 2025 10:53
Copy link
Contributor
@sarahboyce sarahboyce left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you! This looks great ⭐

@sarahboyce sarahboyce merged commit 69ab6e5 into django:main May 9, 2025
29 of 30 checks passed
@jacobtylerwalls jacobtylerwalls deleted the fix-refresh-foreign-object branch May 9, 2025 12:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants
0