fix: KeyError when accessing fields deferred by base manager#907
Conversation
There was a problem hiding this comment.
Pull request overview
Fixes a regression where accessing model fields that are deferred by a polymorphic model’s base manager can raise KeyError, by ensuring polymorphic_ctype_id is added to Django’s deferred-loading configuration after Django’s .only() logic has determined immediate vs deferred fields.
Changes:
- Adjust
PolymorphicQuerySet.only()to injectpolymorphic_ctype_idby post-processingclone.query.deferred_loading, avoiding interference with Django’s deferred-field set-difference logic. - Add a regression test case and supporting test model/DB migration to reproduce “base manager defers a field” behavior.
- Update
justfilesetting syntax (set unstable).
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| src/polymorphic/query.py | Changes .only() handling so polymorphic_ctype_id is included without breaking Django’s deferred-field tracking. |
| src/polymorphic/tests/test_orm.py | Adds regression test that exercises deferred field access through a base manager that uses .defer(). |
| src/polymorphic/tests/models.py | Introduces a test-only manager and model variant to defer a field via the base manager. |
| src/polymorphic/tests/migrations/0001_initial.py | Adds migration for the new test model so the test DB schema matches. |
| src/polymorphic/tests/test_utils.py | Updates concrete descendant expectations to include the new test model. |
| justfile | Updates just setting syntax for unstable. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
a291045 to
e5cd637
Compare
bckohan
left a comment
There was a problem hiding this comment.
This is a solid, correct fix. I traced the mechanics and verified it end-to-end.
Root cause. Django stores field-loading intent as query.deferred_loading = (field_set, defer_flag):
- (fields, True) → defer these (load everything else)
- (fields, False) → load only these
- (∅, False) → the empty-only special case, which Django's deferred_to_data treats as load all fields
The scenario: a model whose base_manager defers field2 (DeferredManager.get_queryset().defer("field2")). When you later access obj.field2, Django's DeferredAttribute.get calls refresh_from_db(fields=["field2"]), which runs ._base_manager.only("field2").get(). That base-manager queryset already carries deferred_loading = ({"field2"}, True). Django's add_immediate_loading then computes {"field2"} - {"field2"} = ∅, flipping to (∅, False) — i.e. "load everything."
The old only() unconditionally injected polymorphic_ctype_id before calling super().only(), so the result became ({"polymorphic_ctype_id"}, False) — "load only ctype." field2 stayed deferred, the refresh never fetched it, and re-reading data["field2"] blew up with KeyError: 'field2'.
The fix moves the injection to after super().only() and gates it on the resulting only-set being non-empty. In the collapse-to-load-all case it leaves (∅, False) alone (all fields load, including ctype); in the normal .only("field1") case it re-adds ctype exactly as before.
Codecov Report✅ All modified and coverable lines are covered by tests.
🚀 New features to boost your workflow:
|
Ensure that injecting
"polymorphic_ctype_id"intodeferred_loadinghappens after Django's.only()implementation has determined which deferred fields to read.closes #906