dynamic.py 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
  1. # orm/dynamic.py
  2. # Copyright (C) 2005-2025 the SQLAlchemy authors and contributors
  3. # <see AUTHORS file>
  4. #
  5. # This module is part of SQLAlchemy and is released under
  6. # the MIT License: https://www.opensource.org/licenses/mit-license.php
  7. """Dynamic collection API.
  8. Dynamic collections act like Query() objects for read operations and support
  9. basic add/delete mutation.
  10. .. legacy:: the "dynamic" loader is a legacy feature, superseded by the
  11. "write_only" loader.
  12. """
  13. from __future__ import annotations
  14. from typing import Any
  15. from typing import Iterable
  16. from typing import Iterator
  17. from typing import List
  18. from typing import Optional
  19. from typing import Tuple
  20. from typing import Type
  21. from typing import TYPE_CHECKING
  22. from typing import TypeVar
  23. from typing import Union
  24. from . import attributes
  25. from . import exc as orm_exc
  26. from . import relationships
  27. from . import util as orm_util
  28. from .base import PassiveFlag
  29. from .query import Query
  30. from .session import object_session
  31. from .writeonly import AbstractCollectionWriter
  32. from .writeonly import WriteOnlyAttributeImpl
  33. from .writeonly import WriteOnlyHistory
  34. from .writeonly import WriteOnlyLoader
  35. from .. import util
  36. from ..engine import result
  37. if TYPE_CHECKING:
  38. from . import QueryableAttribute
  39. from .mapper import Mapper
  40. from .relationships import _RelationshipOrderByArg
  41. from .session import Session
  42. from .state import InstanceState
  43. from .util import AliasedClass
  44. from ..event import _Dispatch
  45. from ..sql.elements import ColumnElement
  46. _T = TypeVar("_T", bound=Any)
  47. class DynamicCollectionHistory(WriteOnlyHistory[_T]):
  48. def __init__(
  49. self,
  50. attr: DynamicAttributeImpl,
  51. state: InstanceState[_T],
  52. passive: PassiveFlag,
  53. apply_to: Optional[DynamicCollectionHistory[_T]] = None,
  54. ) -> None:
  55. if apply_to:
  56. coll = AppenderQuery(attr, state).autoflush(False)
  57. self.unchanged_items = util.OrderedIdentitySet(coll)
  58. self.added_items = apply_to.added_items
  59. self.deleted_items = apply_to.deleted_items
  60. self._reconcile_collection = True
  61. else:
  62. self.deleted_items = util.OrderedIdentitySet()
  63. self.added_items = util.OrderedIdentitySet()
  64. self.unchanged_items = util.OrderedIdentitySet()
  65. self._reconcile_collection = False
  66. class DynamicAttributeImpl(WriteOnlyAttributeImpl):
  67. _supports_dynamic_iteration = True
  68. collection_history_cls = DynamicCollectionHistory[Any]
  69. query_class: Type[AppenderMixin[Any]] # type: ignore[assignment]
  70. def __init__(
  71. self,
  72. class_: Union[Type[Any], AliasedClass[Any]],
  73. key: str,
  74. dispatch: _Dispatch[QueryableAttribute[Any]],
  75. target_mapper: Mapper[_T],
  76. order_by: _RelationshipOrderByArg,
  77. query_class: Optional[Type[AppenderMixin[_T]]] = None,
  78. **kw: Any,
  79. ) -> None:
  80. attributes.AttributeImpl.__init__(
  81. self, class_, key, None, dispatch, **kw
  82. )
  83. self.target_mapper = target_mapper
  84. if order_by:
  85. self.order_by = tuple(order_by)
  86. if not query_class:
  87. self.query_class = AppenderQuery
  88. elif AppenderMixin in query_class.mro():
  89. self.query_class = query_class
  90. else:
  91. self.query_class = mixin_user_query(query_class)
  92. @relationships.RelationshipProperty.strategy_for(lazy="dynamic")
  93. class DynaLoader(WriteOnlyLoader):
  94. impl_class = DynamicAttributeImpl
  95. class AppenderMixin(AbstractCollectionWriter[_T]):
  96. """A mixin that expects to be mixing in a Query class with
  97. AbstractAppender.
  98. """
  99. query_class: Optional[Type[Query[_T]]] = None
  100. _order_by_clauses: Tuple[ColumnElement[Any], ...]
  101. def __init__(
  102. self, attr: DynamicAttributeImpl, state: InstanceState[_T]
  103. ) -> None:
  104. Query.__init__(
  105. self, # type: ignore[arg-type]
  106. attr.target_mapper,
  107. None,
  108. )
  109. super().__init__(attr, state)
  110. @property
  111. def session(self) -> Optional[Session]:
  112. sess = object_session(self.instance)
  113. if sess is not None and sess.autoflush and self.instance in sess:
  114. sess.flush()
  115. if not orm_util.has_identity(self.instance):
  116. return None
  117. else:
  118. return sess
  119. @session.setter
  120. def session(self, session: Session) -> None:
  121. self.sess = session
  122. def _iter(self) -> Union[result.ScalarResult[_T], result.Result[_T]]:
  123. sess = self.session
  124. if sess is None:
  125. state = attributes.instance_state(self.instance)
  126. if state.detached:
  127. util.warn(
  128. "Instance %s is detached, dynamic relationship cannot "
  129. "return a correct result. This warning will become "
  130. "a DetachedInstanceError in a future release."
  131. % (orm_util.state_str(state))
  132. )
  133. return result.IteratorResult(
  134. result.SimpleResultMetaData([self.attr.class_.__name__]),
  135. iter(
  136. self.attr._get_collection_history(
  137. attributes.instance_state(self.instance),
  138. PassiveFlag.PASSIVE_NO_INITIALIZE,
  139. ).added_items
  140. ),
  141. _source_supports_scalars=True,
  142. ).scalars()
  143. else:
  144. return self._generate(sess)._iter()
  145. if TYPE_CHECKING:
  146. def __iter__(self) -> Iterator[_T]: ...
  147. def __getitem__(self, index: Any) -> Union[_T, List[_T]]:
  148. sess = self.session
  149. if sess is None:
  150. return self.attr._get_collection_history(
  151. attributes.instance_state(self.instance),
  152. PassiveFlag.PASSIVE_NO_INITIALIZE,
  153. ).indexed(index)
  154. else:
  155. return self._generate(sess).__getitem__(index) # type: ignore[no-any-return] # noqa: E501
  156. def count(self) -> int:
  157. sess = self.session
  158. if sess is None:
  159. return len(
  160. self.attr._get_collection_history(
  161. attributes.instance_state(self.instance),
  162. PassiveFlag.PASSIVE_NO_INITIALIZE,
  163. ).added_items
  164. )
  165. else:
  166. return self._generate(sess).count()
  167. def _generate(
  168. self,
  169. sess: Optional[Session] = None,
  170. ) -> Query[_T]:
  171. # note we're returning an entirely new Query class instance
  172. # here without any assignment capabilities; the class of this
  173. # query is determined by the session.
  174. instance = self.instance
  175. if sess is None:
  176. sess = object_session(instance)
  177. if sess is None:
  178. raise orm_exc.DetachedInstanceError(
  179. "Parent instance %s is not bound to a Session, and no "
  180. "contextual session is established; lazy load operation "
  181. "of attribute '%s' cannot proceed"
  182. % (orm_util.instance_str(instance), self.attr.key)
  183. )
  184. if self.query_class:
  185. query = self.query_class(self.attr.target_mapper, session=sess)
  186. else:
  187. query = sess.query(self.attr.target_mapper)
  188. query._where_criteria = self._where_criteria
  189. query._from_obj = self._from_obj
  190. query._order_by_clauses = self._order_by_clauses
  191. return query
  192. def add_all(self, iterator: Iterable[_T]) -> None:
  193. """Add an iterable of items to this :class:`_orm.AppenderQuery`.
  194. The given items will be persisted to the database in terms of
  195. the parent instance's collection on the next flush.
  196. This method is provided to assist in delivering forwards-compatibility
  197. with the :class:`_orm.WriteOnlyCollection` collection class.
  198. .. versionadded:: 2.0
  199. """
  200. self._add_all_impl(iterator)
  201. def add(self, item: _T) -> None:
  202. """Add an item to this :class:`_orm.AppenderQuery`.
  203. The given item will be persisted to the database in terms of
  204. the parent instance's collection on the next flush.
  205. This method is provided to assist in delivering forwards-compatibility
  206. with the :class:`_orm.WriteOnlyCollection` collection class.
  207. .. versionadded:: 2.0
  208. """
  209. self._add_all_impl([item])
  210. def extend(self, iterator: Iterable[_T]) -> None:
  211. """Add an iterable of items to this :class:`_orm.AppenderQuery`.
  212. The given items will be persisted to the database in terms of
  213. the parent instance's collection on the next flush.
  214. """
  215. self._add_all_impl(iterator)
  216. def append(self, item: _T) -> None:
  217. """Append an item to this :class:`_orm.AppenderQuery`.
  218. The given item will be persisted to the database in terms of
  219. the parent instance's collection on the next flush.
  220. """
  221. self._add_all_impl([item])
  222. def remove(self, item: _T) -> None:
  223. """Remove an item from this :class:`_orm.AppenderQuery`.
  224. The given item will be removed from the parent instance's collection on
  225. the next flush.
  226. """
  227. self._remove_impl(item)
  228. class AppenderQuery(AppenderMixin[_T], Query[_T]): # type: ignore[misc]
  229. """A dynamic query that supports basic collection storage operations.
  230. Methods on :class:`.AppenderQuery` include all methods of
  231. :class:`_orm.Query`, plus additional methods used for collection
  232. persistence.
  233. """
  234. def mixin_user_query(cls: Any) -> type[AppenderMixin[Any]]:
  235. """Return a new class with AppenderQuery functionality layered over."""
  236. name = "Appender" + cls.__name__
  237. return type(name, (AppenderMixin, cls), {"query_class": cls})