summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMike Bayer <mike_mp@zzzcomputing.com>2023-04-27 16:48:25 -0400
committerMike Bayer <mike_mp@zzzcomputing.com>2023-04-29 01:07:53 -0400
commite7e09649761cfb4afc242c541ab403258e75edd5 (patch)
treedaa3360ec06e4c10dd2efcc80f4f51198c7b7424
parent1329037bfed428e458547824a861ce1aa9df0c78 (diff)
downloadsqlalchemy-e7e09649761cfb4afc242c541ab403258e75edd5.tar.gz
improve natural_path usage in two places
Fixed loader strategy pathing issues where eager loaders such as :func:`_orm.joinedload` / :func:`_orm.selectinload` would fail to traverse fully for many-levels deep following a load that had a :func:`_orm.with_polymorphic` or similar construct as an interim member. Here we can take advantage of 2.0's refactoring of strategy_options to identify the "chop_path" concept can be simplified to work with "natural" paths alone. In addition, identified existing logic in PropRegistry that works fine, but needed the "is_unnatural" attribute to be more accurate for a given path, so we set that up front to True if the ancestor is_unnatural. Fixes: #9715 Change-Id: Ie6b3f55b6a23d0d32628afd22437094263745114
-rw-r--r--doc/build/changelog/unreleased_20/9715.rst8
-rw-r--r--lib/sqlalchemy/orm/path_registry.py11
-rw-r--r--lib/sqlalchemy/orm/strategies.py1
-rw-r--r--lib/sqlalchemy/orm/strategy_options.py54
-rw-r--r--lib/sqlalchemy/testing/fixtures.py3
-rw-r--r--test/orm/inheritance/test_assorted_poly.py211
6 files changed, 248 insertions, 40 deletions
diff --git a/doc/build/changelog/unreleased_20/9715.rst b/doc/build/changelog/unreleased_20/9715.rst
new file mode 100644
index 000000000..107051b72
--- /dev/null
+++ b/doc/build/changelog/unreleased_20/9715.rst
@@ -0,0 +1,8 @@
+.. change::
+ :tags: bug, orm
+ :tickets: 9715
+
+ Fixed loader strategy pathing issues where eager loaders such as
+ :func:`_orm.joinedload` / :func:`_orm.selectinload` would fail to traverse
+ fully for many-levels deep following a load that had a
+ :func:`_orm.with_polymorphic` or similar construct as an interim member.
diff --git a/lib/sqlalchemy/orm/path_registry.py b/lib/sqlalchemy/orm/path_registry.py
index e7084fbf6..e1cbd9313 100644
--- a/lib/sqlalchemy/orm/path_registry.py
+++ b/lib/sqlalchemy/orm/path_registry.py
@@ -119,6 +119,8 @@ class PathRegistry(HasCacheKey):
is_property = False
is_entity = False
+ is_unnatural: bool
+
path: _PathRepresentation
natural_path: _PathRepresentation
parent: Optional[PathRegistry]
@@ -510,7 +512,12 @@ class PropRegistry(PathRegistry):
# given MapperProperty's parent.
insp = cast("_InternalEntityType[Any]", parent[-1])
natural_parent: AbstractEntityRegistry = parent
- self.is_unnatural = False
+
+ # inherit "is_unnatural" from the parent
+ if parent.parent.is_unnatural:
+ self.is_unnatural = True
+ else:
+ self.is_unnatural = False
if not insp.is_aliased_class or insp._use_mapper_path: # type: ignore
parent = natural_parent = parent.parent[prop.parent]
@@ -570,6 +577,7 @@ class PropRegistry(PathRegistry):
self.parent = parent
self.path = parent.path + (prop,)
self.natural_path = natural_parent.natural_path + (prop,)
+
self.has_entity = prop._links_to_entity
if prop._is_relationship:
if TYPE_CHECKING:
@@ -674,7 +682,6 @@ class AbstractEntityRegistry(CreatesToken):
# elif not parent.path and self.is_aliased_class:
# self.natural_path = (self.entity._generate_cache_key()[0], )
else:
- # self.natural_path = parent.natural_path + (entity, )
self.natural_path = self.path
def _truncate_recursive(self) -> AbstractEntityRegistry:
diff --git a/lib/sqlalchemy/orm/strategies.py b/lib/sqlalchemy/orm/strategies.py
index 5581e5c7f..8e06c4f59 100644
--- a/lib/sqlalchemy/orm/strategies.py
+++ b/lib/sqlalchemy/orm/strategies.py
@@ -1063,6 +1063,7 @@ class LazyLoader(
if extra_options:
stmt._with_options += extra_options
+
stmt._compile_options += {"_current_path": effective_path}
if use_get:
diff --git a/lib/sqlalchemy/orm/strategy_options.py b/lib/sqlalchemy/orm/strategy_options.py
index 48e69aef2..2e073f326 100644
--- a/lib/sqlalchemy/orm/strategy_options.py
+++ b/lib/sqlalchemy/orm/strategy_options.py
@@ -927,7 +927,9 @@ class _AbstractLoad(traversals.GenerativeOnTraversal, LoaderOption):
) -> Optional[_PathRepresentation]:
i = -1
- for i, (c_token, p_token) in enumerate(zip(to_chop, path.path)):
+ for i, (c_token, p_token) in enumerate(
+ zip(to_chop, path.natural_path)
+ ):
if isinstance(c_token, str):
if i == 0 and c_token.endswith(f":{_DEFAULT_TOKEN}"):
return to_chop
@@ -942,36 +944,8 @@ class _AbstractLoad(traversals.GenerativeOnTraversal, LoaderOption):
elif (
isinstance(c_token, InspectionAttr)
and insp_is_mapper(c_token)
- and (
- (insp_is_mapper(p_token) and c_token.isa(p_token))
- or (
- # a too-liberal check here to allow a path like
- # A->A.bs->B->B.cs->C->C.ds, natural path, to chop
- # against current path
- # A->A.bs->B(B, B2)->B(B, B2)->cs, in an of_type()
- # scenario which should only be occurring in a loader
- # that is against a non-aliased lead element with
- # single path. otherwise the
- # "B" won't match into the B(B, B2).
- #
- # i>=2 prevents this check from proceeding for
- # the first path element.
- #
- # if we could do away with the "natural_path"
- # concept, we would not need guessy checks like this
- #
- # two conflicting tests for this comparison are:
- # test_eager_relations.py->
- # test_lazyload_aliased_abs_bcs_two
- # and
- # test_of_type.py->test_all_subq_query
- #
- i >= 2
- and insp_is_aliased_class(p_token)
- and p_token._is_with_polymorphic
- and c_token in p_token.with_polymorphic_mappers
- )
- )
+ and insp_is_mapper(p_token)
+ and c_token.isa(p_token)
):
continue
@@ -1321,7 +1295,7 @@ class _WildcardLoad(_AbstractLoad):
strategy: Optional[Tuple[Any, ...]]
local_opts: _OptsType
- path: Tuple[str, ...]
+ path: Union[Tuple[()], Tuple[str]]
propagate_to_loaders = False
def __init__(self) -> None:
@@ -1366,6 +1340,7 @@ class _WildcardLoad(_AbstractLoad):
it may be used as the sub-option of a :class:`_orm.Load` object.
"""
+ assert self.path
attr = self.path[0]
if attr.endswith(_DEFAULT_TOKEN):
attr = f"{attr.split(':')[0]}:{_WILDCARD_TOKEN}"
@@ -1396,13 +1371,16 @@ class _WildcardLoad(_AbstractLoad):
start_path: _PathRepresentation = self.path
- # TODO: chop_path already occurs in loader.process_compile_state()
- # so we will seek to simplify this
if current_path:
+ # TODO: no cases in test suite where we actually get
+ # None back here
new_path = self._chop_path(start_path, current_path)
- if not new_path:
+ if new_path is None:
return
- start_path = new_path
+
+ # chop_path does not actually "chop" a wildcard token path,
+ # just returns it
+ assert new_path == start_path
# start_path is a single-token tuple
assert start_path and len(start_path) == 1
@@ -1618,7 +1596,9 @@ class _LoadElement(
"""
- chopped_start_path = Load._chop_path(effective_path.path, current_path)
+ chopped_start_path = Load._chop_path(
+ effective_path.natural_path, current_path
+ )
if not chopped_start_path:
return None
diff --git a/lib/sqlalchemy/testing/fixtures.py b/lib/sqlalchemy/testing/fixtures.py
index fc1fa1483..bff251b0f 100644
--- a/lib/sqlalchemy/testing/fixtures.py
+++ b/lib/sqlalchemy/testing/fixtures.py
@@ -13,6 +13,7 @@ import itertools
import random
import re
import sys
+from typing import Any
import sqlalchemy as sa
from . import assertions
@@ -675,7 +676,7 @@ class MappedTest(TablesTest, assertions.AssertsExecutionResults):
# 'once', 'each', None
run_setup_mappers = "each"
- classes = None
+ classes: Any = None
@config.fixture(autouse=True, scope="class")
def _setup_tables_test_class(self):
diff --git a/test/orm/inheritance/test_assorted_poly.py b/test/orm/inheritance/test_assorted_poly.py
index 4bebc9b10..a40a9ae74 100644
--- a/test/orm/inheritance/test_assorted_poly.py
+++ b/test/orm/inheritance/test_assorted_poly.py
@@ -3,6 +3,10 @@ These are generally tests derived from specific user issues.
"""
+from __future__ import annotations
+
+from typing import Optional
+
from sqlalchemy import exists
from sqlalchemy import ForeignKey
from sqlalchemy import func
@@ -17,10 +21,14 @@ from sqlalchemy.orm import aliased
from sqlalchemy.orm import class_mapper
from sqlalchemy.orm import column_property
from sqlalchemy.orm import contains_eager
+from sqlalchemy.orm import immediateload
from sqlalchemy.orm import join
from sqlalchemy.orm import joinedload
+from sqlalchemy.orm import Mapped
+from sqlalchemy.orm import mapped_column
from sqlalchemy.orm import polymorphic_union
from sqlalchemy.orm import relationship
+from sqlalchemy.orm import selectinload
from sqlalchemy.orm import Session
from sqlalchemy.orm import sessionmaker
from sqlalchemy.orm import with_polymorphic
@@ -2554,3 +2562,206 @@ class Issue8168Test(AssertsCompiledSQL, fixtures.TestBase):
)
else:
scenario.fail()
+
+
+class PolyIntoSelfReferentialTest(
+ fixtures.DeclarativeMappedTest, AssertsExecutionResults
+):
+ """test for #9715"""
+
+ @classmethod
+ def setup_classes(cls):
+ Base = cls.DeclarativeBasic
+
+ class A(Base):
+ __tablename__ = "a"
+
+ id: Mapped[int] = mapped_column(
+ primary_key=True, autoincrement=True
+ )
+
+ rel_id: Mapped[int] = mapped_column(ForeignKey("related.id"))
+
+ related = relationship("Related")
+
+ class Related(Base):
+ __tablename__ = "related"
+
+ id: Mapped[int] = mapped_column(
+ primary_key=True, autoincrement=True
+ )
+ rel_data: Mapped[str]
+ type: Mapped[str] = mapped_column()
+
+ other_related_id: Mapped[int] = mapped_column(
+ ForeignKey("other_related.id")
+ )
+
+ other_related = relationship("OtherRelated")
+
+ __mapper_args__ = {
+ "polymorphic_identity": "related",
+ "polymorphic_on": type,
+ }
+
+ class SubRelated(Related):
+ __tablename__ = "sub_related"
+
+ id: Mapped[int] = mapped_column(
+ ForeignKey("related.id"), primary_key=True
+ )
+ sub_rel_data: Mapped[str]
+
+ __mapper_args__ = {"polymorphic_identity": "sub_related"}
+
+ class OtherRelated(Base):
+ __tablename__ = "other_related"
+
+ id: Mapped[int] = mapped_column(
+ primary_key=True, autoincrement=True
+ )
+ name: Mapped[str]
+
+ parent_id: Mapped[Optional[int]] = mapped_column(
+ ForeignKey("other_related.id")
+ )
+ parent = relationship("OtherRelated", lazy="raise", remote_side=id)
+
+ @classmethod
+ def insert_data(cls, connection):
+ A, SubRelated, OtherRelated = cls.classes(
+ "A", "SubRelated", "OtherRelated"
+ )
+
+ with Session(connection) as sess:
+
+ grandparent_otherrel1 = OtherRelated(name="GP1")
+ grandparent_otherrel2 = OtherRelated(name="GP2")
+
+ parent_otherrel1 = OtherRelated(
+ name="P1", parent=grandparent_otherrel1
+ )
+ parent_otherrel2 = OtherRelated(
+ name="P2", parent=grandparent_otherrel2
+ )
+
+ otherrel1 = OtherRelated(name="A1", parent=parent_otherrel1)
+ otherrel3 = OtherRelated(name="A2", parent=parent_otherrel2)
+
+ address1 = SubRelated(
+ rel_data="ST1", other_related=otherrel1, sub_rel_data="w1"
+ )
+ address3 = SubRelated(
+ rel_data="ST2", other_related=otherrel3, sub_rel_data="w2"
+ )
+
+ a1 = A(related=address1)
+ a2 = A(related=address3)
+
+ sess.add_all([a1, a2])
+ sess.commit()
+
+ def _run_load(self, *opt):
+ A = self.classes.A
+ stmt = select(A).options(*opt)
+
+ sess = fixture_session()
+ all_a = sess.scalars(stmt).all()
+
+ sess.close()
+
+ with self.assert_statement_count(testing.db, 0):
+ for a1 in all_a:
+ d1 = a1.related
+ d2 = d1.other_related
+ d3 = d2.parent
+ d4 = d3.parent
+ assert d4.name in ("GP1", "GP2")
+
+ @testing.variation("use_workaround", [True, False])
+ def test_workaround(self, use_workaround):
+ A, Related, SubRelated, OtherRelated = self.classes(
+ "A", "Related", "SubRelated", "OtherRelated"
+ )
+
+ related = with_polymorphic(Related, [SubRelated], flat=True)
+
+ opt = [
+ (
+ joinedload(A.related.of_type(related))
+ .joinedload(related.other_related)
+ .joinedload(
+ OtherRelated.parent,
+ )
+ )
+ ]
+ if use_workaround:
+ opt.append(
+ joinedload(
+ A.related,
+ Related.other_related,
+ OtherRelated.parent,
+ OtherRelated.parent,
+ )
+ )
+ else:
+ opt[0] = opt[0].joinedload(OtherRelated.parent)
+
+ self._run_load(*opt)
+
+ @testing.combinations(
+ (("joined", "joined", "joined", "joined"),),
+ (("selectin", "selectin", "selectin", "selectin"),),
+ (("selectin", "selectin", "joined", "joined"),),
+ (("selectin", "selectin", "joined", "selectin"),),
+ (("joined", "selectin", "joined", "selectin"),),
+ # TODO: immediateload (and lazyload) do not support the target item
+ # being a with_polymorphic. this seems to be a limitation in the
+ # current_path logic
+ # (("immediate", "joined", "joined", "joined"),),
+ argnames="loaders",
+ )
+ @testing.variation("use_wpoly", [True, False])
+ def test_all_load(self, loaders, use_wpoly):
+ A, Related, SubRelated, OtherRelated = self.classes(
+ "A", "Related", "SubRelated", "OtherRelated"
+ )
+
+ if use_wpoly:
+ related = with_polymorphic(Related, [SubRelated], flat=True)
+ else:
+ related = SubRelated
+
+ opt = None
+ for i, (load_type, element) in enumerate(
+ zip(
+ loaders,
+ [
+ A.related.of_type(related),
+ related.other_related,
+ OtherRelated.parent,
+ OtherRelated.parent,
+ ],
+ )
+ ):
+ if i == 0:
+ if load_type == "joined":
+ opt = joinedload(element)
+ elif load_type == "selectin":
+ opt = selectinload(element)
+ elif load_type == "immediate":
+ opt = immediateload(element)
+ else:
+ assert False
+ else:
+ assert opt is not None
+ if load_type == "joined":
+ opt = opt.joinedload(element)
+ elif load_type == "selectin":
+ opt = opt.selectinload(element)
+ elif load_type == "immediate":
+ opt = opt.immediateload(element)
+ else:
+ assert False
+
+ self._run_load(opt)