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)
Discussed in #9528
the wildcard option '*' acts recursively in some sitiuations (with no way to not be recursive) and non-recursively in others:
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: