Skip to content

relationship loader options do a "recursive" thing inconsistently #9548

Description

@zzzeek

Discussed in #9528

the wildcard option '*' acts recursively in some sitiuations (with no way to not be recursive) and non-recursively in others:

from sqlalchemy import Column
from sqlalchemy import create_engine
from sqlalchemy import ForeignKey
from sqlalchemy import Integer
from sqlalchemy import select
from sqlalchemy.orm import declarative_base
from sqlalchemy.orm import lazyload
from sqlalchemy.orm import relationship
from sqlalchemy.orm import selectinload
from sqlalchemy.orm import Session

Base = declarative_base()


class Parent(Base):
    __tablename__ = "parent"

    def __init__(self, **kw):
        kw.setdefault('children', [])
        super().__init__(**kw)

    id = Column(Integer, primary_key=True)

    children = relationship(
        "Child",
        cascade="all, delete-orphan",
        lazy="raise",
    )

    other_thing = relationship("OtherThing", lazy="raise")
    other_id = Column(ForeignKey('other_thing.id'))

class OtherThing(Base):
    __tablename__ = "other_thing"
    id = Column(Integer, primary_key=True)


class Child(Base):
    __tablename__ = "child"

    id = Column(Integer, primary_key=True)
    parent_id = Column(ForeignKey("parent.id"))
    sub_children = relationship(
        "SubChild", lazy="raise", cascade="all, delete-orphan"
    )


class SubChild(Base):
    __tablename__ = "subchild"

    id = Column(Integer, primary_key=True)
    parent_id = Column(ForeignKey("child.id"))


e = create_engine("sqlite://", echo=True)
Base.metadata.create_all(e)

# fixture data
with Session(e) as sess:
    sess.add(Parent(other_thing=OtherThing(), children=[Child(sub_children=[SubChild()]), Child()]))
    sess.commit()

with Session(e) as sess:
    # is recursive ( which is surprising?)
    p1 = sess.scalars(select(Parent).options(selectinload('*'))).one()


    # works
    for c in p1.children:
        c.sub_children

with Session(e) as sess:
    # is not recursive because wildcard does not have propagate=True
    p1 = sess.scalars(select(Parent).options(lazyload('*'))).one()

    # fails
    for c in p1.children:
        c.sub_children

propose the best API would be:

"*" - acts one level deep only
"**" - is recursive

there's some backwards incompat here, so we'll think about the best way to do it. however the basic idea would be:

diff --git a/lib/sqlalchemy/orm/path_registry.py b/lib/sqlalchemy/orm/path_registry.py
index b117f59f78..a974b69bb0 100644
--- a/lib/sqlalchemy/orm/path_registry.py
+++ b/lib/sqlalchemy/orm/path_registry.py
@@ -44,6 +44,7 @@ if TYPE_CHECKING:
     from ..sql.visitors import anon_map
     from ..util.typing import _LiteralStar
     from ..util.typing import TypeGuard
+    from ..util.typing import Literal
 
     def is_root(path: PathRegistry) -> TypeGuard[RootRegistry]:
         ...
@@ -82,6 +83,7 @@ def _unreduce_path(path: _SerializedPath) -> PathRegistry:
 
 
 _WILDCARD_TOKEN: _LiteralStar = "*"
+_RECURSIVE_WILDCARD_TOKEN: Literal["**"] = "**"
 _DEFAULT_TOKEN = "_sa_default"
 
 
diff --git a/lib/sqlalchemy/orm/strategy_options.py b/lib/sqlalchemy/orm/strategy_options.py
index ba4b12061a..fe7180f79b 100644
--- a/lib/sqlalchemy/orm/strategy_options.py
+++ b/lib/sqlalchemy/orm/strategy_options.py
@@ -35,6 +35,7 @@ from .base import InspectionAttr
 from .interfaces import LoaderOption
 from .path_registry import _DEFAULT_TOKEN
 from .path_registry import _WILDCARD_TOKEN
+from .path_registry import _RECURSIVE_WILDCARD_TOKEN
 from .path_registry import AbstractEntityRegistry
 from .path_registry import path_is_property
 from .path_registry import PathRegistry
@@ -1314,13 +1315,14 @@ class Load(_AbstractLoad):
 
 
 class _WildcardLoad(_AbstractLoad):
-    """represent a standalone '*' load operation"""
+    """represent a standalone '*' or '**' load operation"""
 
-    __slots__ = ("strategy", "path", "local_opts")
+    __slots__ = ("strategy", "path", "local_opts", "recursive")
 
     _traverse_internals = [
         ("strategy", visitors.ExtendedInternalTraversal.dp_plain_obj),
         ("path", visitors.ExtendedInternalTraversal.dp_plain_obj),
+        ("recursive", visitors.ExtendedInternalTraversal.dp_boolean),
         (
             "local_opts",
             visitors.ExtendedInternalTraversal.dp_string_multi_dict,
@@ -1331,12 +1333,17 @@ class _WildcardLoad(_AbstractLoad):
     strategy: Optional[Tuple[Any, ...]]
     local_opts: _OptsType
     path: Tuple[str, ...]
-    propagate_to_loaders = False
+    recursive: bool
 
-    def __init__(self) -> None:
+    def __init__(self, recursive) -> None:
         self.path = ()
         self.strategy = None
         self.local_opts = util.EMPTY_DICT
+        self.recursive = recursive
+
+    @property
+    def propagate_to_loaders(self):
+        return self.recursive
 
     def _clone_for_bind_strategy(
         self,
@@ -1403,6 +1410,9 @@ class _WildcardLoad(_AbstractLoad):
         entities = [ent.entity_zero for ent in mapper_entities]
         current_path = compile_state.current_path
 
+        if current_path and not self.propagate_to_loaders:
+            return
+
         start_path: _PathRepresentation = self.path
 
         # TODO: chop_path already occurs in loader.process_compile_state()
@@ -2245,7 +2255,7 @@ def _generate_from_keys(
                     )
                     attr = attr[1:]
 
-                if attr == _WILDCARD_TOKEN:
+                if attr in (_WILDCARD_TOKEN, _RECURSIVE_WILDCARD_TOKEN):
                     if is_default:
                         raise sa_exc.ArgumentError(
                             "Wildcard token cannot be followed by "
@@ -2253,7 +2263,9 @@ def _generate_from_keys(
                         )
 
                     if lead_element is None:
-                        lead_element = _WildcardLoad()
+                        lead_element = _WildcardLoad(
+                            recursive=attr == _RECURSIVE_WILDCARD_TOKEN
+                        )
 
                     lead_element = meth(lead_element, _DEFAULT_TOKEN, **kw)
 

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workinghigh priorityloader optionsORM options like joinedload(), load_only(), these are complicated and have a lot of issuesorm

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions