writeonly.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674
  1. # orm/writeonly.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. """Write-only collection API.
  8. This is an alternate mapped attribute style that only supports single-item
  9. collection mutation operations. To read the collection, a select()
  10. object must be executed each time.
  11. .. versionadded:: 2.0
  12. """
  13. from __future__ import annotations
  14. from typing import Any
  15. from typing import Collection
  16. from typing import Dict
  17. from typing import Generic
  18. from typing import Iterable
  19. from typing import Iterator
  20. from typing import List
  21. from typing import NoReturn
  22. from typing import Optional
  23. from typing import overload
  24. from typing import Tuple
  25. from typing import Type
  26. from typing import TYPE_CHECKING
  27. from typing import TypeVar
  28. from typing import Union
  29. from sqlalchemy.sql import bindparam
  30. from . import attributes
  31. from . import interfaces
  32. from . import relationships
  33. from . import strategies
  34. from .base import NEVER_SET
  35. from .base import object_mapper
  36. from .base import PassiveFlag
  37. from .base import RelationshipDirection
  38. from .. import exc
  39. from .. import inspect
  40. from .. import log
  41. from .. import util
  42. from ..sql import delete
  43. from ..sql import insert
  44. from ..sql import select
  45. from ..sql import update
  46. from ..sql.dml import Delete
  47. from ..sql.dml import Insert
  48. from ..sql.dml import Update
  49. from ..util.typing import Literal
  50. if TYPE_CHECKING:
  51. from . import QueryableAttribute
  52. from ._typing import _InstanceDict
  53. from .attributes import AttributeEventToken
  54. from .base import LoaderCallableStatus
  55. from .collections import _AdaptedCollectionProtocol
  56. from .collections import CollectionAdapter
  57. from .mapper import Mapper
  58. from .relationships import _RelationshipOrderByArg
  59. from .state import InstanceState
  60. from .util import AliasedClass
  61. from ..event import _Dispatch
  62. from ..sql.selectable import FromClause
  63. from ..sql.selectable import Select
  64. _T = TypeVar("_T", bound=Any)
  65. class WriteOnlyHistory(Generic[_T]):
  66. """Overrides AttributeHistory to receive append/remove events directly."""
  67. unchanged_items: util.OrderedIdentitySet
  68. added_items: util.OrderedIdentitySet
  69. deleted_items: util.OrderedIdentitySet
  70. _reconcile_collection: bool
  71. def __init__(
  72. self,
  73. attr: WriteOnlyAttributeImpl,
  74. state: InstanceState[_T],
  75. passive: PassiveFlag,
  76. apply_to: Optional[WriteOnlyHistory[_T]] = None,
  77. ) -> None:
  78. if apply_to:
  79. if passive & PassiveFlag.SQL_OK:
  80. raise exc.InvalidRequestError(
  81. f"Attribute {attr} can't load the existing state from the "
  82. "database for this operation; full iteration is not "
  83. "permitted. If this is a delete operation, configure "
  84. f"passive_deletes=True on the {attr} relationship in "
  85. "order to resolve this error."
  86. )
  87. self.unchanged_items = apply_to.unchanged_items
  88. self.added_items = apply_to.added_items
  89. self.deleted_items = apply_to.deleted_items
  90. self._reconcile_collection = apply_to._reconcile_collection
  91. else:
  92. self.deleted_items = util.OrderedIdentitySet()
  93. self.added_items = util.OrderedIdentitySet()
  94. self.unchanged_items = util.OrderedIdentitySet()
  95. self._reconcile_collection = False
  96. @property
  97. def added_plus_unchanged(self) -> List[_T]:
  98. return list(self.added_items.union(self.unchanged_items))
  99. @property
  100. def all_items(self) -> List[_T]:
  101. return list(
  102. self.added_items.union(self.unchanged_items).union(
  103. self.deleted_items
  104. )
  105. )
  106. def as_history(self) -> attributes.History:
  107. if self._reconcile_collection:
  108. added = self.added_items.difference(self.unchanged_items)
  109. deleted = self.deleted_items.intersection(self.unchanged_items)
  110. unchanged = self.unchanged_items.difference(deleted)
  111. else:
  112. added, unchanged, deleted = (
  113. self.added_items,
  114. self.unchanged_items,
  115. self.deleted_items,
  116. )
  117. return attributes.History(list(added), list(unchanged), list(deleted))
  118. def indexed(self, index: Union[int, slice]) -> Union[List[_T], _T]:
  119. return list(self.added_items)[index]
  120. def add_added(self, value: _T) -> None:
  121. self.added_items.add(value)
  122. def add_removed(self, value: _T) -> None:
  123. if value in self.added_items:
  124. self.added_items.remove(value)
  125. else:
  126. self.deleted_items.add(value)
  127. class WriteOnlyAttributeImpl(
  128. attributes.HasCollectionAdapter, attributes.AttributeImpl
  129. ):
  130. uses_objects: bool = True
  131. default_accepts_scalar_loader: bool = False
  132. supports_population: bool = False
  133. _supports_dynamic_iteration: bool = False
  134. collection: bool = False
  135. dynamic: bool = True
  136. order_by: _RelationshipOrderByArg = ()
  137. collection_history_cls: Type[WriteOnlyHistory[Any]] = WriteOnlyHistory
  138. query_class: Type[WriteOnlyCollection[Any]]
  139. def __init__(
  140. self,
  141. class_: Union[Type[Any], AliasedClass[Any]],
  142. key: str,
  143. dispatch: _Dispatch[QueryableAttribute[Any]],
  144. target_mapper: Mapper[_T],
  145. order_by: _RelationshipOrderByArg,
  146. **kw: Any,
  147. ):
  148. super().__init__(class_, key, None, dispatch, **kw)
  149. self.target_mapper = target_mapper
  150. self.query_class = WriteOnlyCollection
  151. if order_by:
  152. self.order_by = tuple(order_by)
  153. def get(
  154. self,
  155. state: InstanceState[Any],
  156. dict_: _InstanceDict,
  157. passive: PassiveFlag = PassiveFlag.PASSIVE_OFF,
  158. ) -> Union[util.OrderedIdentitySet, WriteOnlyCollection[Any]]:
  159. if not passive & PassiveFlag.SQL_OK:
  160. return self._get_collection_history(
  161. state, PassiveFlag.PASSIVE_NO_INITIALIZE
  162. ).added_items
  163. else:
  164. return self.query_class(self, state)
  165. @overload
  166. def get_collection(
  167. self,
  168. state: InstanceState[Any],
  169. dict_: _InstanceDict,
  170. user_data: Literal[None] = ...,
  171. passive: Literal[PassiveFlag.PASSIVE_OFF] = ...,
  172. ) -> CollectionAdapter: ...
  173. @overload
  174. def get_collection(
  175. self,
  176. state: InstanceState[Any],
  177. dict_: _InstanceDict,
  178. user_data: _AdaptedCollectionProtocol = ...,
  179. passive: PassiveFlag = ...,
  180. ) -> CollectionAdapter: ...
  181. @overload
  182. def get_collection(
  183. self,
  184. state: InstanceState[Any],
  185. dict_: _InstanceDict,
  186. user_data: Optional[_AdaptedCollectionProtocol] = ...,
  187. passive: PassiveFlag = ...,
  188. ) -> Union[
  189. Literal[LoaderCallableStatus.PASSIVE_NO_RESULT], CollectionAdapter
  190. ]: ...
  191. def get_collection(
  192. self,
  193. state: InstanceState[Any],
  194. dict_: _InstanceDict,
  195. user_data: Optional[_AdaptedCollectionProtocol] = None,
  196. passive: PassiveFlag = PassiveFlag.PASSIVE_OFF,
  197. ) -> Union[
  198. Literal[LoaderCallableStatus.PASSIVE_NO_RESULT], CollectionAdapter
  199. ]:
  200. data: Collection[Any]
  201. if not passive & PassiveFlag.SQL_OK:
  202. data = self._get_collection_history(state, passive).added_items
  203. else:
  204. history = self._get_collection_history(state, passive)
  205. data = history.added_plus_unchanged
  206. return DynamicCollectionAdapter(data) # type: ignore[return-value]
  207. @util.memoized_property
  208. def _append_token(self) -> attributes.AttributeEventToken:
  209. return attributes.AttributeEventToken(self, attributes.OP_APPEND)
  210. @util.memoized_property
  211. def _remove_token(self) -> attributes.AttributeEventToken:
  212. return attributes.AttributeEventToken(self, attributes.OP_REMOVE)
  213. def fire_append_event(
  214. self,
  215. state: InstanceState[Any],
  216. dict_: _InstanceDict,
  217. value: Any,
  218. initiator: Optional[AttributeEventToken],
  219. collection_history: Optional[WriteOnlyHistory[Any]] = None,
  220. ) -> None:
  221. if collection_history is None:
  222. collection_history = self._modified_event(state, dict_)
  223. collection_history.add_added(value)
  224. for fn in self.dispatch.append:
  225. value = fn(state, value, initiator or self._append_token)
  226. if self.trackparent and value is not None:
  227. self.sethasparent(attributes.instance_state(value), state, True)
  228. def fire_remove_event(
  229. self,
  230. state: InstanceState[Any],
  231. dict_: _InstanceDict,
  232. value: Any,
  233. initiator: Optional[AttributeEventToken],
  234. collection_history: Optional[WriteOnlyHistory[Any]] = None,
  235. ) -> None:
  236. if collection_history is None:
  237. collection_history = self._modified_event(state, dict_)
  238. collection_history.add_removed(value)
  239. if self.trackparent and value is not None:
  240. self.sethasparent(attributes.instance_state(value), state, False)
  241. for fn in self.dispatch.remove:
  242. fn(state, value, initiator or self._remove_token)
  243. def _modified_event(
  244. self, state: InstanceState[Any], dict_: _InstanceDict
  245. ) -> WriteOnlyHistory[Any]:
  246. if self.key not in state.committed_state:
  247. state.committed_state[self.key] = self.collection_history_cls(
  248. self, state, PassiveFlag.PASSIVE_NO_FETCH
  249. )
  250. state._modified_event(dict_, self, NEVER_SET)
  251. # this is a hack to allow the entities.ComparableEntity fixture
  252. # to work
  253. dict_[self.key] = True
  254. return state.committed_state[self.key] # type: ignore[no-any-return]
  255. def set(
  256. self,
  257. state: InstanceState[Any],
  258. dict_: _InstanceDict,
  259. value: Any,
  260. initiator: Optional[AttributeEventToken] = None,
  261. passive: PassiveFlag = PassiveFlag.PASSIVE_OFF,
  262. check_old: Any = None,
  263. pop: bool = False,
  264. _adapt: bool = True,
  265. ) -> None:
  266. if initiator and initiator.parent_token is self.parent_token:
  267. return
  268. if pop and value is None:
  269. return
  270. iterable = value
  271. new_values = list(iterable)
  272. if state.has_identity:
  273. if not self._supports_dynamic_iteration:
  274. raise exc.InvalidRequestError(
  275. f'Collection "{self}" does not support implicit '
  276. "iteration; collection replacement operations "
  277. "can't be used"
  278. )
  279. old_collection = util.IdentitySet(
  280. self.get(state, dict_, passive=passive)
  281. )
  282. collection_history = self._modified_event(state, dict_)
  283. if not state.has_identity:
  284. old_collection = collection_history.added_items
  285. else:
  286. old_collection = old_collection.union(
  287. collection_history.added_items
  288. )
  289. constants = old_collection.intersection(new_values)
  290. additions = util.IdentitySet(new_values).difference(constants)
  291. removals = old_collection.difference(constants)
  292. for member in new_values:
  293. if member in additions:
  294. self.fire_append_event(
  295. state,
  296. dict_,
  297. member,
  298. None,
  299. collection_history=collection_history,
  300. )
  301. for member in removals:
  302. self.fire_remove_event(
  303. state,
  304. dict_,
  305. member,
  306. None,
  307. collection_history=collection_history,
  308. )
  309. def delete(self, *args: Any, **kwargs: Any) -> NoReturn:
  310. raise NotImplementedError()
  311. def set_committed_value(
  312. self, state: InstanceState[Any], dict_: _InstanceDict, value: Any
  313. ) -> NoReturn:
  314. raise NotImplementedError(
  315. "Dynamic attributes don't support collection population."
  316. )
  317. def get_history(
  318. self,
  319. state: InstanceState[Any],
  320. dict_: _InstanceDict,
  321. passive: PassiveFlag = PassiveFlag.PASSIVE_NO_FETCH,
  322. ) -> attributes.History:
  323. c = self._get_collection_history(state, passive)
  324. return c.as_history()
  325. def get_all_pending(
  326. self,
  327. state: InstanceState[Any],
  328. dict_: _InstanceDict,
  329. passive: PassiveFlag = PassiveFlag.PASSIVE_NO_INITIALIZE,
  330. ) -> List[Tuple[InstanceState[Any], Any]]:
  331. c = self._get_collection_history(state, passive)
  332. return [(attributes.instance_state(x), x) for x in c.all_items]
  333. def _get_collection_history(
  334. self, state: InstanceState[Any], passive: PassiveFlag
  335. ) -> WriteOnlyHistory[Any]:
  336. c: WriteOnlyHistory[Any]
  337. if self.key in state.committed_state:
  338. c = state.committed_state[self.key]
  339. else:
  340. c = self.collection_history_cls(
  341. self, state, PassiveFlag.PASSIVE_NO_FETCH
  342. )
  343. if state.has_identity and (passive & PassiveFlag.INIT_OK):
  344. return self.collection_history_cls(
  345. self, state, passive, apply_to=c
  346. )
  347. else:
  348. return c
  349. def append(
  350. self,
  351. state: InstanceState[Any],
  352. dict_: _InstanceDict,
  353. value: Any,
  354. initiator: Optional[AttributeEventToken],
  355. passive: PassiveFlag = PassiveFlag.PASSIVE_NO_FETCH,
  356. ) -> None:
  357. if initiator is not self:
  358. self.fire_append_event(state, dict_, value, initiator)
  359. def remove(
  360. self,
  361. state: InstanceState[Any],
  362. dict_: _InstanceDict,
  363. value: Any,
  364. initiator: Optional[AttributeEventToken],
  365. passive: PassiveFlag = PassiveFlag.PASSIVE_NO_FETCH,
  366. ) -> None:
  367. if initiator is not self:
  368. self.fire_remove_event(state, dict_, value, initiator)
  369. def pop(
  370. self,
  371. state: InstanceState[Any],
  372. dict_: _InstanceDict,
  373. value: Any,
  374. initiator: Optional[AttributeEventToken],
  375. passive: PassiveFlag = PassiveFlag.PASSIVE_NO_FETCH,
  376. ) -> None:
  377. self.remove(state, dict_, value, initiator, passive=passive)
  378. @log.class_logger
  379. @relationships.RelationshipProperty.strategy_for(lazy="write_only")
  380. class WriteOnlyLoader(strategies.AbstractRelationshipLoader, log.Identified):
  381. impl_class = WriteOnlyAttributeImpl
  382. def init_class_attribute(self, mapper: Mapper[Any]) -> None:
  383. self.is_class_level = True
  384. if not self.uselist or self.parent_property.direction not in (
  385. interfaces.ONETOMANY,
  386. interfaces.MANYTOMANY,
  387. ):
  388. raise exc.InvalidRequestError(
  389. "On relationship %s, 'dynamic' loaders cannot be used with "
  390. "many-to-one/one-to-one relationships and/or "
  391. "uselist=False." % self.parent_property
  392. )
  393. strategies._register_attribute( # type: ignore[no-untyped-call]
  394. self.parent_property,
  395. mapper,
  396. useobject=True,
  397. impl_class=self.impl_class,
  398. target_mapper=self.parent_property.mapper,
  399. order_by=self.parent_property.order_by,
  400. query_class=self.parent_property.query_class,
  401. )
  402. class DynamicCollectionAdapter:
  403. """simplified CollectionAdapter for internal API consistency"""
  404. data: Collection[Any]
  405. def __init__(self, data: Collection[Any]):
  406. self.data = data
  407. def __iter__(self) -> Iterator[Any]:
  408. return iter(self.data)
  409. def _reset_empty(self) -> None:
  410. pass
  411. def __len__(self) -> int:
  412. return len(self.data)
  413. def __bool__(self) -> bool:
  414. return True
  415. class AbstractCollectionWriter(Generic[_T]):
  416. """Virtual collection which includes append/remove methods that synchronize
  417. into the attribute event system.
  418. """
  419. if not TYPE_CHECKING:
  420. __slots__ = ()
  421. instance: _T
  422. _from_obj: Tuple[FromClause, ...]
  423. def __init__(self, attr: WriteOnlyAttributeImpl, state: InstanceState[_T]):
  424. instance = state.obj()
  425. if TYPE_CHECKING:
  426. assert instance
  427. self.instance = instance
  428. self.attr = attr
  429. mapper = object_mapper(instance)
  430. prop = mapper._props[self.attr.key]
  431. if prop.secondary is not None:
  432. # this is a hack right now. The Query only knows how to
  433. # make subsequent joins() without a given left-hand side
  434. # from self._from_obj[0]. We need to ensure prop.secondary
  435. # is in the FROM. So we purposely put the mapper selectable
  436. # in _from_obj[0] to ensure a user-defined join() later on
  437. # doesn't fail, and secondary is then in _from_obj[1].
  438. # note also, we are using the official ORM-annotated selectable
  439. # from __clause_element__(), see #7868
  440. self._from_obj = (prop.mapper.__clause_element__(), prop.secondary)
  441. else:
  442. self._from_obj = ()
  443. self._where_criteria = (
  444. prop._with_parent(instance, alias_secondary=False),
  445. )
  446. if self.attr.order_by:
  447. self._order_by_clauses = self.attr.order_by
  448. else:
  449. self._order_by_clauses = ()
  450. def _add_all_impl(self, iterator: Iterable[_T]) -> None:
  451. for item in iterator:
  452. self.attr.append(
  453. attributes.instance_state(self.instance),
  454. attributes.instance_dict(self.instance),
  455. item,
  456. None,
  457. )
  458. def _remove_impl(self, item: _T) -> None:
  459. self.attr.remove(
  460. attributes.instance_state(self.instance),
  461. attributes.instance_dict(self.instance),
  462. item,
  463. None,
  464. )
  465. class WriteOnlyCollection(AbstractCollectionWriter[_T]):
  466. """Write-only collection which can synchronize changes into the
  467. attribute event system.
  468. The :class:`.WriteOnlyCollection` is used in a mapping by
  469. using the ``"write_only"`` lazy loading strategy with
  470. :func:`_orm.relationship`. For background on this configuration,
  471. see :ref:`write_only_relationship`.
  472. .. versionadded:: 2.0
  473. .. seealso::
  474. :ref:`write_only_relationship`
  475. """
  476. __slots__ = (
  477. "instance",
  478. "attr",
  479. "_where_criteria",
  480. "_from_obj",
  481. "_order_by_clauses",
  482. )
  483. def __iter__(self) -> NoReturn:
  484. raise TypeError(
  485. "WriteOnly collections don't support iteration in-place; "
  486. "to query for collection items, use the select() method to "
  487. "produce a SQL statement and execute it with session.scalars()."
  488. )
  489. def select(self) -> Select[Tuple[_T]]:
  490. """Produce a :class:`_sql.Select` construct that represents the
  491. rows within this instance-local :class:`_orm.WriteOnlyCollection`.
  492. """
  493. stmt = select(self.attr.target_mapper).where(*self._where_criteria)
  494. if self._from_obj:
  495. stmt = stmt.select_from(*self._from_obj)
  496. if self._order_by_clauses:
  497. stmt = stmt.order_by(*self._order_by_clauses)
  498. return stmt
  499. def insert(self) -> Insert:
  500. """For one-to-many collections, produce a :class:`_dml.Insert` which
  501. will insert new rows in terms of this this instance-local
  502. :class:`_orm.WriteOnlyCollection`.
  503. This construct is only supported for a :class:`_orm.Relationship`
  504. that does **not** include the :paramref:`_orm.relationship.secondary`
  505. parameter. For relationships that refer to a many-to-many table,
  506. use ordinary bulk insert techniques to produce new objects, then
  507. use :meth:`_orm.AbstractCollectionWriter.add_all` to associate them
  508. with the collection.
  509. """
  510. state = inspect(self.instance)
  511. mapper = state.mapper
  512. prop = mapper._props[self.attr.key]
  513. if prop.direction is not RelationshipDirection.ONETOMANY:
  514. raise exc.InvalidRequestError(
  515. "Write only bulk INSERT only supported for one-to-many "
  516. "collections; for many-to-many, use a separate bulk "
  517. "INSERT along with add_all()."
  518. )
  519. dict_: Dict[str, Any] = {}
  520. for l, r in prop.synchronize_pairs:
  521. fn = prop._get_attr_w_warn_on_none(
  522. mapper,
  523. state,
  524. state.dict,
  525. l,
  526. )
  527. dict_[r.key] = bindparam(None, callable_=fn)
  528. return insert(self.attr.target_mapper).values(**dict_)
  529. def update(self) -> Update:
  530. """Produce a :class:`_dml.Update` which will refer to rows in terms
  531. of this instance-local :class:`_orm.WriteOnlyCollection`.
  532. """
  533. return update(self.attr.target_mapper).where(*self._where_criteria)
  534. def delete(self) -> Delete:
  535. """Produce a :class:`_dml.Delete` which will refer to rows in terms
  536. of this instance-local :class:`_orm.WriteOnlyCollection`.
  537. """
  538. return delete(self.attr.target_mapper).where(*self._where_criteria)
  539. def add_all(self, iterator: Iterable[_T]) -> None:
  540. """Add an iterable of items to this :class:`_orm.WriteOnlyCollection`.
  541. The given items will be persisted to the database in terms of
  542. the parent instance's collection on the next flush.
  543. """
  544. self._add_all_impl(iterator)
  545. def add(self, item: _T) -> None:
  546. """Add an item to this :class:`_orm.WriteOnlyCollection`.
  547. The given item will be persisted to the database in terms of
  548. the parent instance's collection on the next flush.
  549. """
  550. self._add_all_impl([item])
  551. def remove(self, item: _T) -> None:
  552. """Remove an item from this :class:`_orm.WriteOnlyCollection`.
  553. The given item will be removed from the parent instance's collection on
  554. the next flush.
  555. """
  556. self._remove_impl(item)