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