Skip to content

Refs #36481 -- Fixed QuerySet.update concrete fields check. #19595

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

Open
wants to merge 1 commit into
base: main
Choose a base branch
from

Conversation

rpkilby
Copy link

@rpkilby rpkilby commented Jun 26, 2025

Trac ticket number

ticket-36481

Branch description

Fixed QuerySet.update concrete fields check.

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.
  • I have added or updated relevant docs, including release notes if applicable.
  • I have attached screenshots in both light and dark modes for any UI changes.

Copy link
Contributor

@nessita nessita 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 @rpkilby for this PR, this is an interesting find! 🌟

I think this could be simplified further, since, assuming A = field.auto_created and B = field.concrete, using DeMorgan Law:

direct = not (A and not B) or not B
direct = not A or not (not B) or not B
direct = not A or B or not B
direct = not A or True
direct = True

So then:

if not direct or (field.is_relation and field.many_to_many):

Becomes if not True or... which is if False or... which is:

if field.is_relation and field.many_to_many:

Wanna push that change?

@nessita
Copy link
Contributor

nessita commented Jun 26, 2025

@charettes am I missing something??? 🤯

@rpkilby
Copy link
Author

rpkilby commented Jun 26, 2025

@nessita There is some context in the attached ticket, but I think it would be valuable to keep the not field.concrete check. In my case, I am subclassing ForeignObject to implement .select_related(...) joins against correlated subqueries, and this would ensure that a FieldError is appropriately raised when calling update against it. I assume this would be helpful for other uses of ForeignObject, where it would not be appropriate to allow an update.

@nessita
Copy link
Contributor

nessita commented Jun 26, 2025

@nessita There is some context in the attached ticket, but I think it would be valuable to keep the not field.concrete check. In my case, I am subclassing ForeignObject to implement .select_related(...) joins against correlated subqueries, and this would ensure that a FieldError is appropriately raised when calling update against it. I assume this would be helpful for other uses of ForeignObject, where it would not be appropriate to allow an update.

Thank you @rpkilby! I did read the ticket, but I still think there are two things in this PR:

  1. Boolean logic simplification: direct can be reduced and removed, as mentioned in my previous comment, since we can prove it's equivalent using boolean algebra.
  2. Maybe a new feature? The change in add_update_values, based on the new test, looks like it slightly changes the logic consequently changing the current behavior. So I think we need to consider that as well. Could this be seen as a breaking change? (honest question)

@rpkilby
Copy link
Author

rpkilby commented Jun 27, 2025

Hi @nessita. I'd argue the two items are related and that this is more of a bugfix. imo this change reflects the intent of the original code, and direct being reducable is only due to the faulty logic, which this would fix.

As to the changed test method, composite keys are a new feature as of 5.2, and ForeignObject is documented as being an internal API. I think raising a different exception is reasonably a bugfix. Additionally, .update() isn't tested against a saved instance, which fails with AttributeError: 'ForeignObjectRel' object has no attribute 'get_related_field'. Raising FieldError: Cannot update model field <django.db.models.fields.related.ForeignObject: user> (only non-relations and foreign keys permitted) seems more correct and consistent than the assertion/value errors. That said, I don't know if there are other non-concrete fields that this change would incorrectly impact.

Updated the test to include both saved and unsaved instances.

Copy link
Member

@charettes charettes left a comment

Choose a reason for hiding this comment

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

imo this change reflects the intent of the original code, and direct being reducable is only due to the faulty logic, which this would fix.

I agree with this conclusion. The logic in add_update_values is there to ensure that only fields backed by actual columns can be updated against as that's a requirement of UPDATE.

Since Field.concrete basically means column is not None only checking against it should be enough? That fact we need the or (field.is_relation and field.many_to_many) part in the first place is likely due to ManyToManyField lacking a proper get_attname_column override.

    def get_attname_column(self):
        attname, column = super().get_attname_column()
        return attname, None

@rpkilby
Copy link
Author

rpkilby commented Jun 27, 2025

@charettes should I commit an update with the following changes:

diff --git a/django/db/models/fields/related.py b/django/db/models/fields/related.py
index bad71a5fd6..e97ecf8a0d 100644
--- a/django/db/models/fields/related.py
+++ b/django/db/models/fields/related.py
@@ -1844,6 +1844,10 @@ class ManyToManyField(RelatedField):
             )
         return name, path, args, kwargs
 
+    def get_attname_column(self):
+        attname, column = super().get_attname_column()
+        return attname, None
+
     def _get_path_info(self, direct=False, filtered_relation=None):
         """Called by both direct and indirect m2m traversal."""
         int_model = self.remote_field.through
diff --git a/django/db/models/sql/subqueries.py b/django/db/models/sql/subqueries.py
index d0e0f82cd5..86f12f95ea 100644
--- a/django/db/models/sql/subqueries.py
+++ b/django/db/models/sql/subqueries.py
@@ -91,10 +91,10 @@ class UpdateQuery(Query):
                 raise FieldError(
                     "Composite primary key fields must be updated individually."
                 )
-            if not field.concrete or (field.is_relation and field.many_to_many):
+            if not field.concrete:
                 raise FieldError(
-                    "Cannot update model field %r (only non-relations and "
-                    "foreign keys permitted)." % field
+                    "Cannot update model field %r (only concrete fields are permitted)."
+                    % field
                 )
             if model is not self.get_meta().concrete_model:
                 self.add_related_update(model, field, val)
diff --git a/tests/update/tests.py b/tests/update/tests.py
index bb83440008..e17d906c80 100644
--- a/tests/update/tests.py
+++ b/tests/update/tests.py
@@ -160,7 +160,7 @@ class AdvancedTests(TestCase):
         msg = (
             "Cannot update model field "
             "<django.db.models.fields.related.ManyToManyField: m2m_foo> "
-            "(only non-relations and foreign keys permitted)."
+            "(only concrete fields are permitted)."
         )
         with self.assertRaisesMessage(FieldError, msg):
             Bar.objects.update(m2m_foo="whatever")

@charettes
Copy link
Member

charettes commented Jun 27, 2025

@rpkilby no lets keep this work focused on dealing with ForeignObject please. It's possible that implementing ManyToManyField.get_attname_column cause other breakages so it should be investigate separately.

@rpkilby
Copy link
Author

rpkilby commented Jun 27, 2025

Thanks - let me know if there's anything else I can to do to help wrap this up.

@rpkilby
Copy link
Author

rpkilby commented Jun 27, 2025

It's possible that implementing ManyToManyField.get_attname_column cause other breakages so it should be investigate separately.

I've reopened https://code.djangoproject.com/ticket/35453, which discusses this question.

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.

3 participants