Skip to content

Comments

swev-id: django__django-15563#514

Open
casey-brooks wants to merge 4 commits intodjango__django-15563from
feature/mti-update-fix-django-15563
Open

swev-id: django__django-15563#514
casey-brooks wants to merge 4 commits intodjango__django-15563from
feature/mti-update-fix-django-15563

Conversation

@casey-brooks
Copy link

Summary

  • add regression coverage for MTI queryset.update with multiple concrete parents
  • ensure UpdateQuery stores per-parent related ids
  • select parent-link values in SQLUpdateCompiler to constrain ancestor updates

Testing

  • PYTHONPATH=$PWD:$PWD/tests:$HOME/.nix-profile/lib/python3.11/site-packages DJANGO_SETTINGS_MODULE=tests.test_sqlite python3 tests/runtests.py model_inheritance.test_mti_update --parallel=1
  • PYTHONPATH=$PWD:$PWD/tests:$HOME/.nix-profile/lib/python3.11/site-packages python3 -m compileall django/db/models/sql/compiler.py django/db/models/sql/subqueries.py tests/model_inheritance/test_mti_update.py

Pre-fix failure

FAIL: test_update_other_parent_targets_related_rows (model_inheritance.test_mti_update.MultiParentUpdateTests.test_update_other_parent_targets_related_rows)
AssertionError: 77 != 303

Resolves #504

@casey-brooks casey-brooks requested a review from a team December 24, 2025 17:07
@casey-brooks
Copy link
Author

Test & Lint Summary

  • PYTHONPATH=$PWD:$PWD/tests:$HOME/.nix-profile/lib/python3.11/site-packages DJANGO_SETTINGS_MODULE=tests.test_sqlite python3 tests/runtests.py model_inheritance.test_mti_update --parallel=1
  • PYTHONPATH=$PWD:$PWD/tests:$HOME/.nix-profile/lib/python3.11/site-packages python3 -m compileall django/db/models/sql/compiler.py django/db/models/sql/subqueries.py tests/model_inheritance/test_mti_update.py

Result: 4 tests passed; linting via compileall succeeded with no errors.

@rowan-stein
Copy link
Collaborator

Requesting review for this fix. References Issue #504.

Copy link

@noa-lucent noa-lucent left a comment

Choose a reason for hiding this comment

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

Thanks for tackling the MTI update bug. I found two places where we silently skip work when the update invariants break: (1) in the compiler we when returns None, and (2) in we fall back to an empty list when a model key is missing. Both cases mask real errors and leave parent tables stale. Please make these code paths fail loudly instead of papering over them.

for model in self.query.related_updates:
parent_link = self.query.get_meta().get_ancestor_link(model)
if parent_link is None:
continue

Choose a reason for hiding this comment

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

[major] returning would mean we somehow enqueued a model in that isn’t actually an ancestor of the base model. That’s an invariant violation, and silently -ing leaves the parent table stale without any signal. In this internal code we should fail loudly instead of hiding the problem. Please turn this into an assertion (or otherwise raise) so that we surface the bug rather than silently skipping the update.

query.values = values
if self.related_ids is not None:
query.add_filter("pk__in", self.related_ids)
ids = self.related_ids.get(model, [])

Choose a reason for hiding this comment

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

[major] now populates per ancestor, so by the time we get here every in should have an entry. Falling back to masks cases where we failed to collect IDs and silently turns the parent update into a no-op. Please require the key (e.g. ) or assert that it exists so we fail loudly when invariants break.

Copy link

@noa-lucent noa-lucent left a comment

Choose a reason for hiding this comment

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

The assertions you added address the invariants I was worried about and prevent silent skips in the parent updates. All previous concerns are resolved from my side.

Copy link

@noa-lucent noa-lucent left a comment

Choose a reason for hiding this comment

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

Thanks for the fix and coverage. I left one blocking note on the new SQL assertion test: as written it still passes against the buggy implementation because the condition accepts the child PKs. Please tighten the assertions so the test fails if we regress.

continue
self.assertIn(" WHERE ", statement)
self.assertIn(" IN ", statement)
self.assertTrue(

Choose a reason for hiding this comment

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

[major] This regression test still passes against the broken implementation. When the UPDATE on incorrectly uses the child PKs (the original bug), the SQL still contains and , so both succeed. To prove we really constrain by the parent-link IDs, the assertions need to check each table separately: e.g. require for the secondary-parent UPDATE and for the primary-parent UPDATE (and ideally assert the wrong IDs are absent). That way the test will fail if we regress to selecting the wrong key. Can you tighten the assertions accordingly?

continue
self.assertIn(" WHERE ", statement)
self.assertIn(" IN ", statement)
self.assertTrue(

Choose a reason for hiding this comment

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

[major] The regression still passes against the buggy implementation. When the SecondaryParent UPDATE mistakenly uses the child PKs (the original bug), the SQL still contains self.child_one.pk/self.child_two.pk, so both assertTrue(... or ...) succeed. To prove we really constrain by each parent-link ID, the test should branch per table — e.g. require child_*_secondary_id for the secondary-parent UPDATE and child_*.pk for the primary-parent UPDATE (and ideally assert that the wrong IDs are absent). Without that tightening, this test won’t fail on a regression.

@casey-brooks
Copy link
Author

Tests & Lint Summary


  • PYTHONPATH=$PWD:$PWD/tests:$HOME/.local/lib/python3.11/site-packages:$HOME/.nix-profile/lib/python3.11/site-packages
    DJANGO_SETTINGS_MODULE=tests.test_sqlite
    python3 tests/runtests.py model_inheritance.test_mti_update --parallel=1 → 4 passed
  • PATH=$HOME/.local/bin:$PATH flake8 tests/model_inheritance/test_mti_update.py → no lint issues

Copy link

@noa-lucent noa-lucent left a comment

Choose a reason for hiding this comment

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

The tightened SQL assertions now force the secondary-parent UPDATE to use the parent-link IDs and fail against the original bug. My previous concern is resolved.

@rowan-stein rowan-stein changed the base branch from django__django-15563 to main January 11, 2026 20:51
@rowan-stein rowan-stein changed the base branch from main to django__django-15563 January 12, 2026 20:14
Copy link

@noa-lucent noa-lucent left a comment

Choose a reason for hiding this comment

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

Thanks for digging into the MTI update regression. The fix currently adds a second query per ancestor and broadens the UpdateQuery API. To keep the patch focused, please revert the related_ids dict changes (and the new MTI test module) and instead extend the existing preselect so it returns the ancestor pk columns in the same round-trip. Once those columns are available we can rebuild the per-parent id lists without cloning new queries or touching the UpdateQuery surface. Happy to take another look once that’s in place.

parent_link is not None
), "Related update requested for non-ancestor model"
parent_query = query.clone()
parent_query.select = []

Choose a reason for hiding this comment

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

[major] This loop is doing a second pass over the update candidates for every ancestor. That gives us 1+n round trips per update and forces the rest of the compiler to grow the dict plumbing. The regression we need to cover is only that the existing preselect wasn’t including the ancestor PK columns. We can fix that by having the first select the child PK plus any parent-link attnames that aren’t already on the primary-key chain, then build the per-parent id lists from that single result set. Could you revert the extra dict machinery and fold the ancestor IDs into the existing preselect instead?}

Copy link
Author

Choose a reason for hiding this comment

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

Updated pre_sql_setup() to fold ancestor IDs into the primary preselect and raise if we ever miss a parent link. Ancestor queries now reuse that single result set without any extra round-trips.

"""
self.values = []
self.related_ids = None
self.related_ids = {}

Choose a reason for hiding this comment

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

[major] Let’s keep UpdateQuery.related_ids as the original list of primary keys. Once pre_sql_setup() returns the ancestor IDs from its single preselect we can fan them out per ancestor without changing the query object’s public surface. The dict cloning/copying here is extra churn that we don’t need once the compiler collects all columns up front. Could you drop this type change and keep the old related_ids storage?

Copy link
Author

Choose a reason for hiding this comment

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

Restored UpdateQuery.related_ids to lists populated by the compiler pass. We now error out if the compiler fails to supply IDs instead of materialising empty dict entries.

if self.related_ids is not None:
query.add_filter("pk__in", self.related_ids)
try:
ids = self.related_ids[model]

Choose a reason for hiding this comment

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

[major] Once the compiler hands us all the ancestor PKs via a single preselect, we can populate the per-model id lists without any KeyError plumbing or extra assertions here. Please revert this catch/raise block and keep the old pk__in call that reused the preselected ids.

Copy link
Author

Choose a reason for hiding this comment

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

Dropped the fallback plumbing—get_related_updates() now expects the compiler to hand back the ancestor PK lists and will raise immediately if any model is missing.

@@ -0,0 +1,259 @@
import re

Choose a reason for hiding this comment

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

[major] The regression coverage shouldn’t need its own 250-line test module with dynamically constructed apps. We can exercise this bug inside the existing suite by extending the current multi-table inheritance cases. Please drop this new file and keep the coverage focused in the existing test module.

Copy link
Author

Choose a reason for hiding this comment

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

Moved the regression coverage into tests/model_inheritance/tests.py so it extends the existing MTI suite instead of living in a standalone module. The tightened SQL assertions now distinguish parent-link IDs from child PKs.

@casey-brooks
Copy link
Author

Summary

  • fold ancestor ID collection into the existing preselect and assert on invalid invariants
  • restore UpdateQuery.related_ids list semantics and move regression coverage into tests/model_inheritance/tests.py

Test & Lint

  • source /workspace/.venv/bin/activate && black django/db/models/sql/compiler.py django/db/models/sql/subqueries.py tests/model_inheritance/tests.py
  • source /workspace/.venv/bin/activate && isort django/db/models/sql/compiler.py django/db/models/sql/subqueries.py tests/model_inheritance/tests.py
  • source /workspace/.venv/bin/activate && flake8 django/db/models/sql/compiler.py django/db/models/sql/subqueries.py tests/model_inheritance/tests.py
  • source /workspace/.venv/bin/activate && PYTHONPATH=/workspace/django python tests/runtests.py model_inheritance --parallel=1 (57 passed)
  • source /workspace/.venv/bin/activate && PYTHONPATH=/workspace/django python tests/runtests.py update --parallel=1 (17 passed, 4 skipped)
  • source /workspace/.venv/bin/activate && PYTHONPATH=/workspace/django python tests/runtests.py model_inheritance_regress --parallel=1 (29 passed, 1 expected failure)

Copy link

@noa-lucent noa-lucent left a comment

Choose a reason for hiding this comment

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

The compiler now folds ancestor parent-link columns into the single preselect and reuses those values to build per-parent pk lists, so the related updates no longer drift onto unrelated rows. The regression coverage exercises mixed parent updates and verifies the emitted SQL constrains each ancestor with the right ids. Looks good to me.

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.

Fix: queryset.update wrong behavior for MTI with multiple concrete parents (swev-id: django__django-15563)

3 participants