From a4c035ce7a6640a7be16e6022521a5e966cf8111 Mon Sep 17 00:00:00 2001 From: Huy Nguyen Date: Fri, 22 May 2026 01:25:57 +0700 Subject: [PATCH] feat: support select_related and prefetch_related for inherited models --- docs/advanced.rst | 28 +- src/polymorphic/query.py | 178 ++++++++++++- .../0002_child_select_prefetch_related.py | 71 ++++++ src/polymorphic/tests/models.py | 38 +++ src/polymorphic/tests/test_orm.py | 241 ++++++++++++++++++ 5 files changed, 545 insertions(+), 11 deletions(-) create mode 100644 src/polymorphic/tests/migrations/0002_child_select_prefetch_related.py diff --git a/docs/advanced.rst b/docs/advanced.rst index ff29145f..70a3ddaa 100644 --- a/docs/advanced.rst +++ b/docs/advanced.rst @@ -247,9 +247,26 @@ About Queryset Methods * :meth:`~django.db.models.query.QuerySet.distinct` works as expected. It only regards the fields of the base class, but this should never make a difference. -* :meth:`~django.db.models.query.QuerySet.select_related` works just as usual, but it can not - (yet) be used to select relations in inherited models (like - ``ModelA.objects.select_related('ModelC___fieldxy')`` ) +* :meth:`~django.db.models.query.QuerySet.select_related` and + :meth:`~django.db.models.query.QuerySet.prefetch_related` work as usual, and support + the ``ModelName___field`` syntax for child-specific relations:: + + # select_related for a FK that only exists on ModelC: + ModelA.objects.select_related('ModelC___fieldxy') + + # prefetch_related for a M2M that only exists on ModelC: + ModelA.objects.prefetch_related('ModelC___some_m2m') + + # Prefetch with a custom queryset: + from django.db.models import Prefetch + ModelA.objects.prefetch_related( + Prefetch('ModelC___some_m2m', queryset=Related.objects.filter(active=True)) + ) + + Child-specific fields are only applied when re-fetching instances of the matching + child class, avoiding unnecessary JOINs on other subtypes. The ``___`` (triple + underscore) syntax is consistent with :meth:`~django.db.models.query.QuerySet.filter`, + :meth:`~django.db.models.query.QuerySet.order_by`, and other queryset methods. * :meth:`~django.db.models.query.QuerySet.extra` works as expected (it returns polymorphic results) but currently has one restriction: The resulting objects are required to have a unique @@ -356,9 +373,8 @@ Restrictions & Caveats * Database Performance regarding concrete Model inheritance in general. Please see :ref:`performance`. -* Queryset methods :meth:`~django.db.models.query.QuerySet.values`, - :meth:`~django.db.models.query.QuerySet.values_list`, and - :meth:`~django.db.models.query.QuerySet.select_related` are not yet fully supported (see above). +* Queryset methods :meth:`~django.db.models.query.QuerySet.values` and + :meth:`~django.db.models.query.QuerySet.values_list` are not yet fully supported (see above). :meth:`~django.db.models.query.QuerySet.extra` has one restriction: the resulting objects are required to have a unique primary key within the result set. diff --git a/src/polymorphic/query.py b/src/polymorphic/query.py index 7d55bd55..195bdb44 100644 --- a/src/polymorphic/query.py +++ b/src/polymorphic/query.py @@ -11,9 +11,9 @@ from typing import TYPE_CHECKING, Any, Generic, cast, overload from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import FieldDoesNotExist +from django.core.exceptions import FieldDoesNotExist, FieldError from django.db import connections, models -from django.db.models import FilteredRelation, Q +from django.db.models import FilteredRelation, Prefetch, Q from django.db.models.expressions import Combinable from django.db.models.query import ModelIterable, QuerySet from typing_extensions import Self, TypeVar @@ -24,7 +24,7 @@ translate_polymorphic_filter_definitions_in_kwargs, translate_polymorphic_Q_object, ) -from .utils import concrete_descendants, route_to_ancestor +from .utils import _map_queryname_to_class, concrete_descendants, route_to_ancestor if TYPE_CHECKING: from .models import PolymorphicModel # noqa: F401 @@ -155,6 +155,8 @@ class PolymorphicQuerySet(QuerySet[_All], Generic[_All, _Base]): polymorphic_disabled: bool polymorphic_deferred_loading: tuple[set[str], bool] + polymorphic_child_select_related: dict[type, list[str]] + polymorphic_child_prefetch_related: dict[type, list[str | Prefetch]] def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) @@ -167,6 +169,11 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: # retrieving the real instance (so that the deferred fields apply # to that queryset as well). self.polymorphic_deferred_loading = (set(), True) + # Child-specific select_related and prefetch_related configs, keyed by + # child model class. These are parsed from the ModelName___field syntax + # and applied per-child in _get_real_instances(). + self.polymorphic_child_select_related = {} + self.polymorphic_child_prefetch_related = {} def _clone(self, *args: Any, **kwargs: Any) -> Self: # Django's _clone only copies its own variables, so we need to copy ours here @@ -176,6 +183,10 @@ def _clone(self, *args: Any, **kwargs: Any) -> Self: copy.copy(self.polymorphic_deferred_loading[0]), self.polymorphic_deferred_loading[1], ) + new.polymorphic_child_select_related = copy.deepcopy(self.polymorphic_child_select_related) + new.polymorphic_child_prefetch_related = copy.deepcopy( + self.polymorphic_child_prefetch_related + ) return new @classmethod @@ -422,6 +433,135 @@ def _values(self, *args: Any, **kwargs: Any) -> Self: # The "polymorphic" keyword argument is not supported anymore. # def extra(self, *args, **kwargs): + def _resolve_child_model_from_lookup(self, lookup_str: str) -> tuple[type, str] | None: + """ + Parse a ``ModelName___field_path`` string and resolve the child model class. + + Returns ``(model_class, field_path)`` if the lookup uses polymorphic + ``___`` syntax, or ``None`` if it should be treated as a regular lookup. + """ + model_name, _, field_path = lookup_str.partition("___") + + # Disambiguate: 'relation___private_field' could be Django's + # 'relation__' + '_private_field', not polymorphic syntax. + # If the first part is a real field, treat as a regular path. + try: + self.model._meta.get_field(model_name) + return None + except FieldDoesNotExist: + pass + + try: + model_class = _map_queryname_to_class(self.model, model_name) + except (AssertionError, FieldError): + return None + + return model_class, field_path + + def select_related(self, *fields: Any) -> Self: + """ + Override select_related to support the ModelName___field syntax for + child-specific fields. Fields using this syntax are separated out and + applied per-child in _get_real_instances(). + """ + if not fields: + return super().select_related() + if fields == (None,): + qs = super().select_related(None) + qs.polymorphic_child_select_related = {} + return qs + + base_fields: list[Any] = [] + child_fields: dict[type, list[str]] = {} + + for field in fields: + if isinstance(field, str) and "___" in field: + resolved = self._resolve_child_model_from_lookup(field) + if resolved is None: + base_fields.append(field) + else: + model_class, field_path = resolved + child_fields.setdefault(model_class, []).append(field_path) + else: + base_fields.append(field) + + if base_fields: + qs = super().select_related(*base_fields) + else: + qs = self._clone() + + for model_class, field_list in child_fields.items(): + qs.polymorphic_child_select_related.setdefault(model_class, []).extend(field_list) + + return qs + + def prefetch_related(self, *lookups: Any) -> Self: + """ + Override prefetch_related to support the ModelName___field syntax for + child-specific lookups. Lookups using this syntax are separated out and + applied per-child in _get_real_instances(). + """ + if lookups == (None,): + qs = super().prefetch_related(None) + qs.polymorphic_child_prefetch_related = {} + return qs + if not lookups: + return super().prefetch_related() + + base_lookups: list[Any] = [] + child_lookups: dict[type, list[str | Prefetch]] = {} + + for lookup in lookups: + lookup_str: str | None = None + if isinstance(lookup, str): + lookup_str = lookup + elif isinstance(lookup, Prefetch): + lookup_str = lookup.prefetch_through + + if lookup_str and "___" in lookup_str: + resolved = self._resolve_child_model_from_lookup(lookup_str) + if resolved is None: + base_lookups.append(lookup) + else: + model_class, field_path = resolved + if isinstance(lookup, str): + child_lookups.setdefault(model_class, []).append(field_path) + else: + new_prefetch = Prefetch( + field_path, + queryset=lookup.queryset, + to_attr=lookup.to_attr, + ) + child_lookups.setdefault(model_class, []).append(new_prefetch) + else: + base_lookups.append(lookup) + + if base_lookups: + qs = super().prefetch_related(*base_lookups) + else: + qs = self._clone() + + for model_class, lookup_list in child_lookups.items(): + qs.polymorphic_child_prefetch_related.setdefault(model_class, []).extend(lookup_list) + + return qs + + def _get_child_select_related_fields(self, child_model: type) -> list[str]: + """Get select_related field paths for a child model, considering inheritance.""" + fields: list[str] = [] + for model_class, field_list in self.polymorphic_child_select_related.items(): + if issubclass(child_model, model_class): + fields.extend(field_list) + return fields + + def _get_child_prefetch_related_lookups(self, child_model: type) -> list[str | Prefetch]: + """Get prefetch_related lookups for a child model, considering inheritance.""" + lookups: list[str | Prefetch] = [] + for model_class, lookup_list in self.polymorphic_child_prefetch_related.items(): + if issubclass(child_model, model_class): + lookups.extend(lookup_list) + return lookups + def _get_real_instances(self, base_result_objects: Sequence[_All]) -> list[_All]: """ Polymorphic object loader @@ -540,8 +680,22 @@ class self.model, but as a class derived from self.model. We want to re-fetch real_objects = real_concrete_class._base_objects.db_manager(self.db).filter( **{(f"{pk_name}__in"): idlist} ) - # copy select related configuration to new qs - real_objects.query.select_related = self.query.select_related + # Copy select_related configuration to new qs (deep copy to avoid + # mutation of the original when child-specific fields are merged) + if isinstance(self.query.select_related, dict): + real_objects.query.select_related = copy.deepcopy(self.query.select_related) + else: + real_objects.query.select_related = self.query.select_related + + # Apply child-specific select_related fields (from ModelName___field syntax) + child_sr_fields = self._get_child_select_related_fields(real_concrete_class) + if child_sr_fields and real_objects.query.select_related is not True: + real_objects = real_objects.select_related(*child_sr_fields) + + # Apply child-specific prefetch_related lookups (from ModelName___field syntax) + child_pr_lookups = self._get_child_prefetch_related_lookups(real_concrete_class) + if child_pr_lookups: + real_objects = real_objects.prefetch_related(*child_pr_lookups) # Copy deferred fields configuration to the new queryset deferred_loading_fields = [] @@ -569,6 +723,20 @@ class self.model, but as a class derived from self.model. We want to re-fetch raise deferred_loading_fields.append(translated_field_name) + + # When in "only" mode (defer=False), child-specific select_related + # FK fields must be included in the non-deferred set, otherwise + # Django raises FieldError ("cannot be both deferred and traversed"). + if child_sr_fields and not self.query.deferred_loading[1]: + for sr_field in child_sr_fields: + fk_field_name = sr_field.split("__")[0] + try: + real_concrete_class._meta.get_field(fk_field_name) + if fk_field_name not in deferred_loading_fields: + deferred_loading_fields.append(fk_field_name) + except FieldDoesNotExist: + pass + real_objects.query.deferred_loading = ( set(deferred_loading_fields), self.query.deferred_loading[1], diff --git a/src/polymorphic/tests/migrations/0002_child_select_prefetch_related.py b/src/polymorphic/tests/migrations/0002_child_select_prefetch_related.py new file mode 100644 index 00000000..b9a6628d --- /dev/null +++ b/src/polymorphic/tests/migrations/0002_child_select_prefetch_related.py @@ -0,0 +1,71 @@ +# Generated by Django 5.2.10 on 2026-05-21 11:26 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('tests', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='ChildRelatedParent', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50)), + ('polymorphic_ctype', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_%(app_label)s.%(class)s_set+', to='contenttypes.contenttype')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='ChildWithFK', + fields=[ + ('childrelatedparent_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='tests.childrelatedparent')), + ('related_plain', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='tests.plaina')), + ], + options={ + 'abstract': False, + }, + bases=('tests.childrelatedparent',), + ), + migrations.CreateModel( + name='GrandChildWithFK', + fields=[ + ('childwithfk_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='tests.childwithfk')), + ('extra_field', models.CharField(blank=True, default='', max_length=30)), + ], + options={ + 'abstract': False, + }, + bases=('tests.childwithfk',), + ), + migrations.CreateModel( + name='ChildWithFKAndM2M', + fields=[ + ('childrelatedparent_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='tests.childrelatedparent')), + ('fk_target', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='tests.modelextraexternal')), + ('m2m_targets', models.ManyToManyField(blank=True, related_name='child_fk_m2m_set', to='tests.modelextraexternal')), + ], + options={ + 'abstract': False, + }, + bases=('tests.childrelatedparent',), + ), + migrations.CreateModel( + name='ChildWithM2M', + fields=[ + ('childrelatedparent_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='tests.childrelatedparent')), + ('m2m_plain', models.ManyToManyField(blank=True, related_name='child_m2m_set', to='tests.plaina')), + ], + options={ + 'abstract': False, + }, + bases=('tests.childrelatedparent',), + ), + ] diff --git a/src/polymorphic/tests/models.py b/src/polymorphic/tests/models.py index a6c95e5d..05058ff8 100644 --- a/src/polymorphic/tests/models.py +++ b/src/polymorphic/tests/models.py @@ -1126,3 +1126,41 @@ class PolymorphicTagB(PolymorphicTagBase): """Second child of PolymorphicTagBase with a different extra field.""" color = models.CharField(max_length=20, default="") + + +# Models for testing child-specific select_related and prefetch_related + + +class ChildRelatedParent(PolymorphicModel): + """Parent model for testing child-specific select_related/prefetch_related.""" + + name = models.CharField(max_length=50) + + +class ChildWithFK(ChildRelatedParent): + """Child with FK for testing select_related with ___ syntax.""" + + related_plain = models.ForeignKey(PlainA, on_delete=models.SET_NULL, null=True, blank=True) + + +class ChildWithM2M(ChildRelatedParent): + """Child with M2M for testing prefetch_related with ___ syntax.""" + + m2m_plain = models.ManyToManyField(PlainA, blank=True, related_name="child_m2m_set") + + +class GrandChildWithFK(ChildWithFK): + """Grandchild to test inheritance of child-specific select_related.""" + + extra_field = models.CharField(max_length=30, blank=True, default="") + + +class ChildWithFKAndM2M(ChildRelatedParent): + """Child with both FK and M2M for combined testing.""" + + fk_target = models.ForeignKey( + ModelExtraExternal, on_delete=models.SET_NULL, null=True, blank=True + ) + m2m_targets = models.ManyToManyField( + ModelExtraExternal, blank=True, related_name="child_fk_m2m_set" + ) diff --git a/src/polymorphic/tests/test_orm.py b/src/polymorphic/tests/test_orm.py index 7b6d5c5d..c744f2a3 100644 --- a/src/polymorphic/tests/test_orm.py +++ b/src/polymorphic/tests/test_orm.py @@ -27,18 +27,26 @@ from polymorphic.models import PolymorphicTypeInvalid, PolymorphicTypeUndefined from polymorphic.tests.models import ( ArtProject, + Author, Base, BlogA, BlogB, BlogBase, BlogEntry, BlogEntry_limit_choices_to, + Book, ChildModelWithManager, + ChildRelatedParent, + ChildWithFK, + ChildWithFKAndM2M, + ChildWithM2M, CustomPkBase, CustomPkInherit, Enhance_Base, Enhance_Plain, Enhance_Inherit, + GrandChildWithFK, + SpecialBook, InlineParent, InlineModelA, InlineModelB, @@ -2633,3 +2641,236 @@ def test_manager_cache_clear_persistence(self): self.test_base_manager() self.test_default_manager() + + def test_select_related_child_specific_fk(self): + """select_related with ModelName___field pre-loads child-specific FK.""" + target = PlainA.objects.create(field1="target") + ChildWithFK.objects.create(name="a", related_plain=target) + ChildRelatedParent.objects.create(name="c") + + # Warm up content type cache + list(ChildRelatedParent.objects.all()) + + # 1 base query + 1 child re-fetch (with JOIN for FK) + with self.assertNumQueries(2): + results = list( + ChildRelatedParent.objects.select_related("ChildWithFK___related_plain") + ) + + # FK is pre-loaded: accessing it triggers zero queries + with self.assertNumQueries(0): + for obj in results: + if isinstance(obj, ChildWithFK): + assert obj.related_plain == target + assert obj.related_plain.field1 == "target" + + # Without select_related: FK access needs extra query + results_no_sr = list(ChildRelatedParent.objects.all()) + with self.assertNumQueries(1): + for obj in results_no_sr: + if isinstance(obj, ChildWithFK): + _ = obj.related_plain + + def test_select_related_grandchild_inherits(self): + """select_related for a child class also applies to grandchildren.""" + target = PlainA.objects.create(field1="target") + GrandChildWithFK.objects.create(name="gc", related_plain=target, extra_field="x") + + qs = ChildRelatedParent.objects.select_related("ChildWithFK___related_plain") + results = list(qs) + assert len(results) == 1 + assert isinstance(results[0], GrandChildWithFK) + + with self.assertNumQueries(0): + assert results[0].related_plain == target + + def test_select_related_chaining(self): + """Multiple select_related calls accumulate child-specific fields.""" + target = PlainA.objects.create(field1="target") + ext = ModelExtraExternal.objects.create(topic="ext") + ChildWithFK.objects.create(name="a", related_plain=target) + ChildWithFKAndM2M.objects.create(name="b", fk_target=ext) + + qs = ChildRelatedParent.objects.select_related( + "ChildWithFK___related_plain" + ).select_related("ChildWithFKAndM2M___fk_target") + results = list(qs) + assert len(results) == 2 + + with self.assertNumQueries(0): + for obj in results: + if isinstance(obj, ChildWithFK): + assert obj.related_plain == target + elif isinstance(obj, ChildWithFKAndM2M): + assert obj.fk_target == ext + + def test_select_related_clear_with_none(self): + """select_related(None) clears both base and child-specific fields.""" + target = PlainA.objects.create(field1="target") + ChildWithFK.objects.create(name="a", related_plain=target) + + qs = ChildRelatedParent.objects.select_related( + "ChildWithFK___related_plain" + ).select_related(None) + results = list(qs) + + # FK should NOT be pre-loaded after clearing + with self.assertNumQueries(1): + for obj in results: + if isinstance(obj, ChildWithFK): + _ = obj.related_plain + + def test_prefetch_related_child_specific_m2m(self): + """prefetch_related with ModelName___field pre-loads child-specific M2M.""" + t1 = PlainA.objects.create(field1="t1") + t2 = PlainA.objects.create(field1="t2") + b = ChildWithM2M.objects.create(name="b") + b.m2m_plain.add(t1, t2) + + # Warm up the content type cache + list(ChildRelatedParent.objects.all()) + + # Queries: 1 base + 1 re-fetch ChildWithM2M + 1 prefetch M2M = 3 + with self.assertNumQueries(3): + results = list(ChildRelatedParent.objects.prefetch_related("ChildWithM2M___m2m_plain")) + + # M2M should be pre-loaded: zero additional queries + with self.assertNumQueries(0): + for obj in results: + if isinstance(obj, ChildWithM2M): + items = list(obj.m2m_plain.all()) + assert len(items) == 2 + + def test_prefetch_related_child_specific_with_prefetch_object(self): + """prefetch_related with Prefetch object using ___ syntax.""" + from django.db.models import Prefetch + + t1 = PlainA.objects.create(field1="alpha") + t2 = PlainA.objects.create(field1="beta") + b = ChildWithM2M.objects.create(name="b") + b.m2m_plain.add(t1, t2) + + qs = ChildRelatedParent.objects.prefetch_related( + Prefetch( + "ChildWithM2M___m2m_plain", + queryset=PlainA.objects.filter(field1__startswith="a"), + ) + ) + results = list(qs) + + with self.assertNumQueries(0): + for obj in results: + if isinstance(obj, ChildWithM2M): + items = list(obj.m2m_plain.all()) + assert len(items) == 1 + assert items[0].field1 == "alpha" + + def test_prefetch_related_child_specific_to_attr(self): + """prefetch_related with Prefetch object and to_attr.""" + from django.db.models import Prefetch + + t1 = PlainA.objects.create(field1="t1") + b = ChildWithM2M.objects.create(name="b") + b.m2m_plain.add(t1) + + qs = ChildRelatedParent.objects.prefetch_related( + Prefetch("ChildWithM2M___m2m_plain", to_attr="cached_m2m") + ) + results = list(qs) + + with self.assertNumQueries(0): + for obj in results: + if isinstance(obj, ChildWithM2M): + assert hasattr(obj, "cached_m2m") + assert len(obj.cached_m2m) == 1 + + def test_prefetch_related_clear_with_none(self): + """prefetch_related(None) clears both base and child-specific lookups.""" + t1 = PlainA.objects.create(field1="t1") + b = ChildWithM2M.objects.create(name="b") + b.m2m_plain.add(t1) + + qs = ChildRelatedParent.objects.prefetch_related( + "ChildWithM2M___m2m_plain" + ).prefetch_related(None) + results = list(qs) + + # M2M should NOT be pre-loaded after clearing + with self.assertNumQueries(1): + for obj in results: + if isinstance(obj, ChildWithM2M): + list(obj.m2m_plain.all()) + + def test_select_and_prefetch_combined(self): + """select_related and prefetch_related can be used together for different children.""" + target_fk = PlainA.objects.create(field1="fk_target") + target_m2m = PlainA.objects.create(field1="m2m_target") + ChildWithFK.objects.create(name="fk_child", related_plain=target_fk) + m2m_child = ChildWithM2M.objects.create(name="m2m_child") + m2m_child.m2m_plain.add(target_m2m) + + qs = ChildRelatedParent.objects.select_related( + "ChildWithFK___related_plain" + ).prefetch_related("ChildWithM2M___m2m_plain") + results = list(qs) + assert len(results) == 2 + + with self.assertNumQueries(0): + for obj in results: + if isinstance(obj, ChildWithFK): + assert obj.related_plain.field1 == "fk_target" + elif isinstance(obj, ChildWithM2M): + items = list(obj.m2m_plain.all()) + assert len(items) == 1 + + def test_only_combined_with_child_select_related(self): + """only() and child-specific select_related work together.""" + target = PlainA.objects.create(field1="target") + ChildWithFK.objects.create(name="a", related_plain=target) + + qs = ChildRelatedParent.objects.only("name").select_related("ChildWithFK___related_plain") + results = list(qs) + assert len(results) == 1 + + with self.assertNumQueries(0): + assert results[0].related_plain == target + + def test_child_select_related_no_matching_children(self): + """Child-specific select_related is a no-op when no matching children exist.""" + ChildRelatedParent.objects.create(name="base_only") + + qs = ChildRelatedParent.objects.select_related("ChildWithFK___related_plain") + results = list(qs) + assert len(results) == 1 + assert type(results[0]) is ChildRelatedParent + + def test_select_related_all_still_works(self): + """select_related() with no args (select all) still errors or works.""" + target = PlainA.objects.create(field1="target") + ChildWithFK.objects.create(name="a", related_plain=target) + + # select_related() with no args should work without errors + qs = ChildRelatedParent.objects.select_related() + results = list(qs) + assert len(results) == 1 + assert isinstance(results[0], ChildWithFK) + # Verify FK is accessible (may or may not require extra query + # depending on Django's select_related behavior with MTI) + assert results[0].related_plain == target + + def test_base_fk_select_related_preserved_after_downcast(self): + """select_related on a base model FK is preserved on downcast child instances.""" + author = Author.objects.create() + Book.objects.create(author=author) + SpecialBook.objects.create(author=author) + + # Warm up content type cache + list(Book.objects.all()) + + qs = Book.objects.select_related("author") + results = list(qs) + assert len(results) == 2 + + with self.assertNumQueries(0): + for obj in results: + assert obj.author == author