]> git.ipfire.org Git - thirdparty/sqlalchemy/sqlalchemy.git/commitdiff
apply _path_with_polymorphic in prepend as well
authorMike Bayer <mike_mp@zzzcomputing.com>
Fri, 27 Mar 2026 18:24:10 +0000 (14:24 -0400)
committerMike Bayer <mike_mp@zzzcomputing.com>
Fri, 27 Mar 2026 20:55:57 +0000 (16:55 -0400)
Fixed issue where using :meth:`_orm.Load.options` to apply a chained loader
option such as :func:`_orm.joinedload` or :func:`_orm.selectinload` with
:meth:`_orm.PropComparator.of_type` for a polymorphic relationship would
not generate the necessary clauses for the polymorphic subclasses. The
polymorphic loading strategy is now correctly propagated when using a call
such as ``joinedload(A.b).options(joinedload(B.c.of_type(poly)))`` to match
the behavior of direct chaining e.g.
``joinedload(A.b).joinedload(B.c.of_type(poly))``.

Fixes: #13202
Change-Id: I7b2ce2dd10a7f8583ff99495b0a65fa1a895ee29

doc/build/changelog/unreleased_20/13202.rst [new file with mode: 0644]
lib/sqlalchemy/orm/strategy_options.py
test/orm/test_of_type.py

diff --git a/doc/build/changelog/unreleased_20/13202.rst b/doc/build/changelog/unreleased_20/13202.rst
new file mode 100644 (file)
index 0000000..0f0559f
--- /dev/null
@@ -0,0 +1,12 @@
+.. change::
+    :tags: bug, orm, inheritance
+    :tickets: 13202
+
+    Fixed issue where using :meth:`_orm.Load.options` to apply a chained loader
+    option such as :func:`_orm.joinedload` or :func:`_orm.selectinload` with
+    :meth:`_orm.PropComparator.of_type` for a polymorphic relationship would
+    not generate the necessary clauses for the polymorphic subclasses. The
+    polymorphic loading strategy is now correctly propagated when using a call
+    such as ``joinedload(A.b).options(joinedload(B.c.of_type(poly)))`` to match
+    the behavior of direct chaining e.g.
+    ``joinedload(A.b).joinedload(B.c.of_type(poly))``.
index bb6a02f0e6a709335ff621c0673acf0b46f8028a..d0ed2803d330b1887382accb9a9b09b905b4b596 100644 (file)
@@ -1579,7 +1579,7 @@ class _LoadElement(
     def is_opts_only(self) -> bool:
         return bool(self.local_opts and self.strategy is None)
 
-    def _clone(self, **kw: Any) -> _LoadElement:
+    def _clone(self, **kw: Any) -> Self:
         cls = self.__class__
         s = cls.__new__(cls)
 
@@ -1800,7 +1800,7 @@ class _LoadElement(
 
         return self._prepend_path(parent.path)
 
-    def _prepend_path(self, path: PathRegistry) -> _LoadElement:
+    def _prepend_path(self, path: PathRegistry) -> Self:
         cloned = self._clone()
 
         assert cloned.strategy == self.strategy
@@ -1969,6 +1969,24 @@ class _AttributeStrategyLoad(_LoadElement):
 
         return path
 
+    def _prepend_path(self, path: PathRegistry) -> Self:
+        """Override to also prepend the path for _path_with_polymorphic_path.
+
+        When using .options() to chain loader options with of_type(), this
+        ensures that the polymorphic path information is correctly updated
+        to include the parent path. Fixes issue #13202.
+        """
+        cloned = super()._prepend_path(path)
+
+        # Also prepend the parent path to _path_with_polymorphic_path if
+        # present
+        if self._path_with_polymorphic_path is not None:
+            cloned._path_with_polymorphic_path = PathRegistry.coerce(
+                path[0:-1] + self._path_with_polymorphic_path[:]
+            )
+
+        return cloned
+
     def _generate_extra_criteria(self, context):
         """Apply the current bound parameters in a QueryContext to the
         immediate "extra_criteria" stored with this Load object.
index 99f124fa33b73a18a05043eaabff0de9a1ec9d2f..12706a8622a4ed2a239c4967817eecda6ab5963b 100644 (file)
@@ -2,12 +2,14 @@ from sqlalchemy import and_
 from sqlalchemy import exc as sa_exc
 from sqlalchemy import ForeignKey
 from sqlalchemy import Integer
+from sqlalchemy import select
 from sqlalchemy import String
 from sqlalchemy import testing
 from sqlalchemy.orm import aliased
 from sqlalchemy.orm import contains_eager
 from sqlalchemy.orm import joinedload
 from sqlalchemy.orm import relationship
+from sqlalchemy.orm import selectinload
 from sqlalchemy.orm import Session
 from sqlalchemy.orm import subqueryload
 from sqlalchemy.orm import with_polymorphic
@@ -1246,3 +1248,126 @@ class SubclassRelationshipTest3(
 
     def test_aliased_join_flat_contains_eager_of_type_b1(self):
         self._test(join_of_type=False, of_type_for_c1=False, aliased_=True)
+
+
+class JoinedloadOfTypeOptionsTest(
+    testing.AssertsCompiledSQL, fixtures.DeclarativeMappedTest
+):
+    """Regression test for issue #13202."""
+
+    run_setup_classes = "once"
+    run_setup_mappers = "once"
+    run_inserts = "once"
+    run_deletes = None
+    __dialect__ = "default"
+
+    @classmethod
+    def setup_classes(cls):
+        Base = cls.DeclarativeBasic
+
+        class A(ComparableEntity, Base):
+            __tablename__ = "a"
+            id = Column(Integer, primary_key=True)
+            bs = relationship("B")
+
+        class B(ComparableEntity, Base):
+            __tablename__ = "b"
+            id = Column(Integer, primary_key=True)
+            a_id = Column(Integer, ForeignKey("a.id"))
+            cs = relationship("C", lazy="joined")
+
+        class C(ComparableEntity, Base):
+            __tablename__ = "c"
+            id = Column(Integer, primary_key=True)
+            b_id = Column(Integer, ForeignKey("b.id"))
+            type = Column(String(50))
+            __mapper_args__ = {"polymorphic_on": type}
+
+        class CSub(C):
+            __tablename__ = "c_sub"
+            id = Column(Integer, ForeignKey("c.id"), primary_key=True)
+            data = Column(String(50))
+            __mapper_args__ = {"polymorphic_identity": "sub"}
+
+    @classmethod
+    def insert_data(cls, connection):
+        with Session(connection) as sess:
+            A, B, CSub = cls.classes("A", "B", "CSub")
+            sess.add(A(bs=[B(cs=[CSub(data="csub1")])]))
+            sess.commit()
+
+    @testing.variation("format_", ["chained", "suboption"])
+    @testing.variation("loader", ["joined", "selectin"])
+    def test_joinedload_of_type_chained_vs_options(
+        self, format_: testing.Variation, loader: testing.Variation
+    ):
+        """Test that joinedload().joinedload(...of_type()) and
+        joinedload().options(joinedload(...of_type())) generate equivalent SQL.
+
+        Regression test for issue #13202 where using .options() to apply
+        a nested joinedload with of_type() would not propagate the
+        polymorphic loading strategy correctly, resulting in missing
+        polymorphic LEFT OUTER JOIN clauses.
+        """
+        A, B, C, CSub = self.classes("A", "B", "C", "CSub")
+
+        c_poly = with_polymorphic(C, "*", flat=True)
+
+        if format_.chained:
+            if loader.selectin:
+                stmt = select(A).options(
+                    selectinload(A.bs).selectinload(B.cs.of_type(c_poly))
+                )
+            elif loader.joined:
+                stmt = select(A).options(
+                    joinedload(A.bs).joinedload(B.cs.of_type(c_poly))
+                )
+            else:
+                loader.fail()
+        elif format_.suboption:
+            if loader.selectin:
+                stmt = select(A).options(
+                    selectinload(A.bs).options(
+                        selectinload(B.cs.of_type(c_poly))
+                    )
+                )
+            elif loader.joined:
+                stmt = select(A).options(
+                    joinedload(A.bs).options(joinedload(B.cs.of_type(c_poly)))
+                )
+            else:
+                loader.fail()
+        else:
+            format_.fail()
+
+        session = fixture_session()
+        with self.sql_execution_asserter(testing.db) as asserter_:
+            eq_(
+                session.scalars(stmt).unique().all(),
+                [A(bs=[B(cs=[CSub(data="csub1")])])],
+            )
+
+        if loader.selectin:
+            asserter_.assert_(
+                CompiledSQL("SELECT a.id FROM a"),
+                CompiledSQL(
+                    "SELECT b.a_id, b.id FROM b WHERE b.a_id IN"
+                    " (__[POSTCOMPILE_primary_keys])"
+                ),
+                CompiledSQL(
+                    "SELECT c_1.b_id, c_1.id, c_1.type, c_sub_1.id,"
+                    " c_sub_1.data FROM c AS c_1 LEFT OUTER JOIN c_sub AS"
+                    " c_sub_1 ON c_1.id = c_sub_1.id WHERE c_1.b_id IN"
+                    " (__[POSTCOMPILE_primary_keys])"
+                ),
+            )
+        elif loader.joined:
+            asserter_.assert_(
+                CompiledSQL(
+                    "SELECT a.id, c_1.id AS id_1, c_1.b_id, c_1.type,"
+                    " c_sub_1.id AS id_2, c_sub_1.data, b_1.id AS id_3,"
+                    " b_1.a_id FROM a LEFT OUTER JOIN b AS b_1 ON a.id ="
+                    " b_1.a_id LEFT OUTER JOIN (c AS c_1 LEFT OUTER JOIN c_sub"
+                    " AS c_sub_1 ON c_1.id = c_sub_1.id) ON b_1.id = c_1.b_id"
+                )
+            )