From: Mike Bayer Date: Fri, 27 Mar 2026 18:24:10 +0000 (-0400) Subject: apply _path_with_polymorphic in prepend as well X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=d3a8d4950e7f1c1cfcabc819e4b85f0bba61e26d;p=thirdparty%2Fsqlalchemy%2Fsqlalchemy.git apply _path_with_polymorphic in prepend as well 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 --- diff --git a/doc/build/changelog/unreleased_20/13202.rst b/doc/build/changelog/unreleased_20/13202.rst new file mode 100644 index 0000000000..0f0559f4eb --- /dev/null +++ b/doc/build/changelog/unreleased_20/13202.rst @@ -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))``. diff --git a/lib/sqlalchemy/orm/strategy_options.py b/lib/sqlalchemy/orm/strategy_options.py index bb6a02f0e6..d0ed2803d3 100644 --- a/lib/sqlalchemy/orm/strategy_options.py +++ b/lib/sqlalchemy/orm/strategy_options.py @@ -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. diff --git a/test/orm/test_of_type.py b/test/orm/test_of_type.py index 99f124fa33..12706a8622 100644 --- a/test/orm/test_of_type.py +++ b/test/orm/test_of_type.py @@ -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" + ) + )